@grida/svg-editor 1.0.0-alpha.13 → 1.0.0-alpha.15
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 +111 -42
- package/dist/dom-CK6GlgFF.d.mts +114 -0
- package/dist/dom-CsKXTaNw.d.ts +112 -0
- package/dist/{dom-BlMk07oX.mjs → dom-DILY80j7.mjs} +1622 -619
- package/dist/{dom-Cvm9Towu.js → dom-Dee6FtgZ.js} +1648 -626
- package/dist/dom.d.mts +3 -3
- package/dist/dom.d.ts +3 -3
- package/dist/dom.js +4 -1
- package/dist/dom.mjs +2 -2
- package/dist/{editor-Bd4-VCEJ.d.ts → editor-BKoo9SPL.d.ts} +643 -25
- package/dist/{editor-DtuRIs-Q.mjs → editor-CvWpD5mu.mjs} +820 -322
- package/dist/{editor-BH03X8cX.d.mts → editor-Dl7c0q5A.d.mts} +643 -25
- package/dist/{editor-CdyC3uAe.js → editor-F8ckj9X1.js} +828 -330
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +5 -4
- package/dist/index.mjs +3 -3
- package/dist/model-B2UWgViT.mjs +3729 -0
- package/dist/model-CJ1Ctq14.js +3875 -0
- package/dist/presets.d.mts +2 -2
- package/dist/presets.d.ts +2 -2
- package/dist/presets.js +3 -3
- package/dist/presets.mjs +2 -2
- package/dist/react.d.mts +18 -6
- package/dist/react.d.ts +18 -6
- package/dist/react.js +24 -3
- package/dist/react.mjs +24 -3
- package/package.json +30 -8
- package/dist/dom-DCX-a8Kr.d.ts +0 -57
- package/dist/dom-DgB4f-TE.d.mts +0 -59
- package/dist/insertions-BJ-6o6o5.js +0 -2399
- package/dist/insertions-Okcuo-Ck.mjs +0 -2176
- /package/dist/{chunk-CfYAbeIz.mjs → chunk-D7D4PA-g.mjs} +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
const
|
|
1
|
+
const require_model = require("./model-CJ1Ctq14.js");
|
|
2
2
|
let _grida_cmath = require("@grida/cmath");
|
|
3
|
-
_grida_cmath =
|
|
3
|
+
_grida_cmath = require_model.__toESM(_grida_cmath);
|
|
4
4
|
let _grida_svg_parse = require("@grida/svg/parse");
|
|
5
|
+
let _grida_svg_pathdata = require("@grida/svg/pathdata");
|
|
6
|
+
let _grida_vn = require("@grida/vn");
|
|
7
|
+
_grida_vn = require_model.__toESM(_grida_vn);
|
|
5
8
|
let _grida_text_editor_dom = require("@grida/text-editor/dom");
|
|
6
9
|
let _grida_hud = require("@grida/hud");
|
|
7
10
|
let _grida_hud_cursors = require("@grida/hud/cursors");
|
|
@@ -296,7 +299,7 @@ function transform_equal(a, b) {
|
|
|
296
299
|
//#region src/core/geometry.ts
|
|
297
300
|
/**
|
|
298
301
|
* Caches `bounds_of` results keyed on `NodeId`; full-clears on either
|
|
299
|
-
* `structure_version` or `geometry_version` bump. See docs/
|
|
302
|
+
* `structure_version` or `geometry_version` bump. See ../../docs/geometry.md for
|
|
300
303
|
* why the cache is load-bearing under the surface's per-tick re-render.
|
|
301
304
|
*/
|
|
302
305
|
var MemoizedGeometryProvider = class {
|
|
@@ -342,6 +345,12 @@ var MemoizedGeometryProvider = class {
|
|
|
342
345
|
node_at_point(p) {
|
|
343
346
|
return this.driver.node_at_point(p);
|
|
344
347
|
}
|
|
348
|
+
/** Pass-through. Frame projection depends on live layout, not on the
|
|
349
|
+
* bounds cache, so there is nothing to memoize. Falls back to the raw
|
|
350
|
+
* delta when the driver can't resolve a frame. */
|
|
351
|
+
world_delta_to_local(id, delta) {
|
|
352
|
+
return this.driver.world_delta_to_local?.(id, delta) ?? delta;
|
|
353
|
+
}
|
|
345
354
|
/** Unsubscribe from both signals. Call on surface detach. */
|
|
346
355
|
dispose() {
|
|
347
356
|
for (const unsub of this.unsubscribers) unsub();
|
|
@@ -671,7 +680,7 @@ function is_self_rendered(doc, id) {
|
|
|
671
680
|
function collect_rendered_subtree(doc, parent, out) {
|
|
672
681
|
for (const child of doc.element_children_of(parent)) {
|
|
673
682
|
if (!is_self_rendered(doc, child)) continue;
|
|
674
|
-
if (!
|
|
683
|
+
if (!require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(child))) continue;
|
|
675
684
|
out.add(child);
|
|
676
685
|
if (doc.tag_of(child) === "g") collect_rendered_subtree(doc, child, out);
|
|
677
686
|
}
|
|
@@ -707,10 +716,10 @@ function compute_neighborhood(doc, dragged) {
|
|
|
707
716
|
for (const id of dragged) {
|
|
708
717
|
const parent = doc.parent_of(id);
|
|
709
718
|
if (parent === null) continue;
|
|
710
|
-
if (!excluded.has(parent) &&
|
|
719
|
+
if (!excluded.has(parent) && require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
|
|
711
720
|
for (const sib of doc.element_children_of(parent)) {
|
|
712
721
|
if (excluded.has(sib)) continue;
|
|
713
|
-
if (!
|
|
722
|
+
if (!require_model.group.STRUCTURAL_GRAPHICS.has(doc.tag_of(sib))) continue;
|
|
714
723
|
if (!is_self_rendered(doc, sib)) continue;
|
|
715
724
|
for (const inner of snap_descent(doc, sib)) {
|
|
716
725
|
if (excluded.has(inner)) continue;
|
|
@@ -727,565 +736,28 @@ const DEFAULT_SNAP_OPTIONS = {
|
|
|
727
736
|
threshold_px: 6
|
|
728
737
|
};
|
|
729
738
|
//#endregion
|
|
730
|
-
//#region src/core/
|
|
731
|
-
/** The funnel. Threads `plan` through `stages` in order; aggregates guide
|
|
732
|
-
* emissions. Pure: same inputs → same outputs. */
|
|
733
|
-
function run_resize_pipeline(init, stages, ctx) {
|
|
734
|
-
let plan = init;
|
|
735
|
-
const guides = [];
|
|
736
|
-
for (const stage of stages) {
|
|
737
|
-
const out = stage.run(plan, ctx);
|
|
738
|
-
plan = out.plan;
|
|
739
|
-
if (out.emit?.guide) guides.push(out.emit.guide);
|
|
740
|
-
}
|
|
741
|
-
return {
|
|
742
|
-
plan,
|
|
743
|
-
guides
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
//#endregion
|
|
747
|
-
//#region src/core/resize-capability.ts
|
|
748
|
-
function direction_mask(dir) {
|
|
749
|
-
const has_n = dir === "n" || dir === "ne" || dir === "nw";
|
|
750
|
-
const has_s = dir === "s" || dir === "se" || dir === "sw";
|
|
751
|
-
const has_e = dir === "e" || dir === "ne" || dir === "se";
|
|
752
|
-
const has_w = dir === "w" || dir === "nw" || dir === "sw";
|
|
753
|
-
return {
|
|
754
|
-
affects_x: has_e || has_w,
|
|
755
|
-
affects_y: has_n || has_s,
|
|
756
|
-
x_edge: has_e ? "right" : has_w ? "left" : null,
|
|
757
|
-
y_edge: has_n ? "top" : has_s ? "bottom" : null
|
|
758
|
-
};
|
|
759
|
-
}
|
|
760
|
-
/** Is this direction one of the four corners (vs. an edge handle)? */
|
|
761
|
-
function is_corner_direction(dir) {
|
|
762
|
-
return dir === "nw" || dir === "ne" || dir === "se" || dir === "sw";
|
|
763
|
-
}
|
|
764
|
-
/**
|
|
765
|
-
* Apply per-element resize constraints to a gesture's proposed `(sx, sy)`.
|
|
766
|
-
*
|
|
767
|
-
* The constraint mirrors `apply_resize` exactly so that the *effective
|
|
768
|
-
* rect* the caller computes from `(sx, sy)` matches what attribute writes
|
|
769
|
-
* actually produce. This is the keystone of resize snap: snapping on the
|
|
770
|
-
* gesture-proposed rect would lie about where the geometry lands when an
|
|
771
|
-
* element-type constraint kicks in.
|
|
772
|
-
*
|
|
773
|
-
* Constraints:
|
|
774
|
-
* - `rect` / `image` / `use` / `ellipse` / `line` / `polyline` /
|
|
775
|
-
* `polygon` / `path`: free per-axis. Identity.
|
|
776
|
-
* - `circle`: uniform. `s = min(sx, sy)`.
|
|
777
|
-
* - `text`: uniform on corner drags; no-op on edge drags. Mirrors the
|
|
778
|
-
* `isCorner = sx !== 1 && sy !== 1` check in `apply_resize`.
|
|
779
|
-
* - `unsupported`: no-op.
|
|
780
|
-
*/
|
|
781
|
-
function resize_constraint(baseline, dir, sx_gesture, sy_gesture) {
|
|
782
|
-
switch (baseline.attrs.kind) {
|
|
783
|
-
case "rect":
|
|
784
|
-
case "image":
|
|
785
|
-
case "use":
|
|
786
|
-
case "ellipse":
|
|
787
|
-
case "line":
|
|
788
|
-
case "polyline":
|
|
789
|
-
case "polygon":
|
|
790
|
-
case "path": return {
|
|
791
|
-
sx: sx_gesture,
|
|
792
|
-
sy: sy_gesture,
|
|
793
|
-
no_op: false,
|
|
794
|
-
uniform: false
|
|
795
|
-
};
|
|
796
|
-
case "circle": {
|
|
797
|
-
const s = Math.min(sx_gesture, sy_gesture);
|
|
798
|
-
return {
|
|
799
|
-
sx: s,
|
|
800
|
-
sy: s,
|
|
801
|
-
no_op: false,
|
|
802
|
-
uniform: true
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
case "text": {
|
|
806
|
-
if (!is_corner_direction(dir)) return {
|
|
807
|
-
sx: 1,
|
|
808
|
-
sy: 1,
|
|
809
|
-
no_op: true,
|
|
810
|
-
uniform: true
|
|
811
|
-
};
|
|
812
|
-
const s = Math.min(sx_gesture, sy_gesture);
|
|
813
|
-
return {
|
|
814
|
-
sx: s,
|
|
815
|
-
sy: s,
|
|
816
|
-
no_op: false,
|
|
817
|
-
uniform: true
|
|
818
|
-
};
|
|
819
|
-
}
|
|
820
|
-
case "unsupported": return {
|
|
821
|
-
sx: 1,
|
|
822
|
-
sy: 1,
|
|
823
|
-
no_op: true,
|
|
824
|
-
uniform: false
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
/** Position of a bbox corner / edge midpoint by direction. */
|
|
829
|
-
function corner_of_rect(r, dir) {
|
|
830
|
-
switch (dir) {
|
|
831
|
-
case "nw": return {
|
|
832
|
-
x: r.x,
|
|
833
|
-
y: r.y
|
|
834
|
-
};
|
|
835
|
-
case "n": return {
|
|
836
|
-
x: r.x + r.width / 2,
|
|
837
|
-
y: r.y
|
|
838
|
-
};
|
|
839
|
-
case "ne": return {
|
|
840
|
-
x: r.x + r.width,
|
|
841
|
-
y: r.y
|
|
842
|
-
};
|
|
843
|
-
case "e": return {
|
|
844
|
-
x: r.x + r.width,
|
|
845
|
-
y: r.y + r.height / 2
|
|
846
|
-
};
|
|
847
|
-
case "se": return {
|
|
848
|
-
x: r.x + r.width,
|
|
849
|
-
y: r.y + r.height
|
|
850
|
-
};
|
|
851
|
-
case "s": return {
|
|
852
|
-
x: r.x + r.width / 2,
|
|
853
|
-
y: r.y + r.height
|
|
854
|
-
};
|
|
855
|
-
case "sw": return {
|
|
856
|
-
x: r.x,
|
|
857
|
-
y: r.y + r.height
|
|
858
|
-
};
|
|
859
|
-
case "w": return {
|
|
860
|
-
x: r.x,
|
|
861
|
-
y: r.y + r.height / 2
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
/** The fixed-origin corner for a drag (opposite the moving corner). */
|
|
866
|
-
function origin_of_direction(r, dir) {
|
|
867
|
-
switch (dir) {
|
|
868
|
-
case "nw": return {
|
|
869
|
-
x: r.x + r.width,
|
|
870
|
-
y: r.y + r.height
|
|
871
|
-
};
|
|
872
|
-
case "n": return {
|
|
873
|
-
x: r.x + r.width / 2,
|
|
874
|
-
y: r.y + r.height
|
|
875
|
-
};
|
|
876
|
-
case "ne": return {
|
|
877
|
-
x: r.x,
|
|
878
|
-
y: r.y + r.height
|
|
879
|
-
};
|
|
880
|
-
case "e": return {
|
|
881
|
-
x: r.x,
|
|
882
|
-
y: r.y + r.height / 2
|
|
883
|
-
};
|
|
884
|
-
case "se": return {
|
|
885
|
-
x: r.x,
|
|
886
|
-
y: r.y
|
|
887
|
-
};
|
|
888
|
-
case "s": return {
|
|
889
|
-
x: r.x + r.width / 2,
|
|
890
|
-
y: r.y
|
|
891
|
-
};
|
|
892
|
-
case "sw": return {
|
|
893
|
-
x: r.x + r.width,
|
|
894
|
-
y: r.y
|
|
895
|
-
};
|
|
896
|
-
case "w": return {
|
|
897
|
-
x: r.x + r.width,
|
|
898
|
-
y: r.y + r.height / 2
|
|
899
|
-
};
|
|
900
|
-
}
|
|
901
|
-
}
|
|
739
|
+
//#region src/core/text-edit.ts
|
|
902
740
|
/**
|
|
903
|
-
*
|
|
904
|
-
* given gesture. This is what snap operates on.
|
|
741
|
+
* Decide what happens when inline text content-editing exits.
|
|
905
742
|
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
|
|
911
|
-
function effective_resize(baseline, dir, sx_gesture, sy_gesture) {
|
|
912
|
-
const bbox = baseline.bbox;
|
|
913
|
-
const origin = origin_of_direction(bbox, dir);
|
|
914
|
-
const c = resize_constraint(baseline, dir, sx_gesture, sy_gesture);
|
|
915
|
-
const mask = direction_mask(dir);
|
|
916
|
-
const rect = {
|
|
917
|
-
x: origin.x + (bbox.x - origin.x) * c.sx,
|
|
918
|
-
y: origin.y + (bbox.y - origin.y) * c.sy,
|
|
919
|
-
width: bbox.width * c.sx,
|
|
920
|
-
height: bbox.height * c.sy
|
|
921
|
-
};
|
|
922
|
-
const moving_corner = corner_of_rect(rect, dir);
|
|
923
|
-
return {
|
|
924
|
-
rect,
|
|
925
|
-
sx: c.sx,
|
|
926
|
-
sy: c.sy,
|
|
927
|
-
moving_corner,
|
|
928
|
-
origin,
|
|
929
|
-
no_op: c.no_op,
|
|
930
|
-
uniform: c.uniform,
|
|
931
|
-
mask
|
|
932
|
-
};
|
|
933
|
-
}
|
|
934
|
-
//#endregion
|
|
935
|
-
//#region src/core/resize-pipeline/stages.ts
|
|
936
|
-
function pipeline_baseline(plan) {
|
|
937
|
-
return plan.baseline;
|
|
938
|
-
}
|
|
939
|
-
/** Collapse `(dx, dy)` to a uniform scale when Shift-drag is active.
|
|
940
|
-
* Element-driven uniform (circle / text-on-corner) is enforced inside
|
|
941
|
-
* `resize_constraint`, not here — this stage is *only* the user-visible
|
|
942
|
-
* modifier. No-op on edge handles (uniform across one axis isn't a
|
|
943
|
-
* meaningful constraint). */
|
|
944
|
-
const stage_aspect_lock = {
|
|
945
|
-
name: "aspect_lock",
|
|
946
|
-
run(plan, ctx) {
|
|
947
|
-
if (ctx.modifiers.aspect_lock !== "uniform") return { plan };
|
|
948
|
-
if (!is_corner_direction(plan.direction)) return { plan };
|
|
949
|
-
const pbase = pipeline_baseline(plan);
|
|
950
|
-
const locked = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, true);
|
|
951
|
-
const bbox = pbase.bbox;
|
|
952
|
-
const Hx_base = (() => {
|
|
953
|
-
switch (plan.direction) {
|
|
954
|
-
case "nw":
|
|
955
|
-
case "w":
|
|
956
|
-
case "sw": return bbox.x;
|
|
957
|
-
case "ne":
|
|
958
|
-
case "e":
|
|
959
|
-
case "se": return bbox.x + bbox.width;
|
|
960
|
-
case "n":
|
|
961
|
-
case "s": return bbox.x + bbox.width / 2;
|
|
962
|
-
}
|
|
963
|
-
})();
|
|
964
|
-
const Hy_base = (() => {
|
|
965
|
-
switch (plan.direction) {
|
|
966
|
-
case "nw":
|
|
967
|
-
case "n":
|
|
968
|
-
case "ne": return bbox.y;
|
|
969
|
-
case "sw":
|
|
970
|
-
case "s":
|
|
971
|
-
case "se": return bbox.y + bbox.height;
|
|
972
|
-
case "e":
|
|
973
|
-
case "w": return bbox.y + bbox.height / 2;
|
|
974
|
-
}
|
|
975
|
-
})();
|
|
976
|
-
const new_Hx = locked.origin.x + (Hx_base - locked.origin.x) * locked.sx;
|
|
977
|
-
const new_Hy = locked.origin.y + (Hy_base - locked.origin.y) * locked.sy;
|
|
978
|
-
return { plan: {
|
|
979
|
-
...plan,
|
|
980
|
-
dx: new_Hx - Hx_base,
|
|
981
|
-
dy: new_Hy - Hy_base
|
|
982
|
-
} };
|
|
983
|
-
}
|
|
984
|
-
};
|
|
985
|
-
/** Consults `ctx.snap_session` for moving-edge alignment correction.
|
|
986
|
-
* Identity on `force_disable_snap`, missing session, or
|
|
987
|
-
* `snap_enabled === false`. */
|
|
988
|
-
const stage_snap = {
|
|
989
|
-
name: "snap",
|
|
990
|
-
run(plan, ctx) {
|
|
991
|
-
if (ctx.modifiers.force_disable_snap) return { plan };
|
|
992
|
-
if (!ctx.snap_session) return { plan };
|
|
993
|
-
if (!ctx.options.snap_enabled) return { plan };
|
|
994
|
-
const pbase = pipeline_baseline(plan);
|
|
995
|
-
const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
|
|
996
|
-
const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
|
|
997
|
-
if (eff.no_op) return { plan };
|
|
998
|
-
const r = ctx.snap_session.snap_resize(eff.rect, {
|
|
999
|
-
x: eff.mask.x_edge,
|
|
1000
|
-
y: eff.mask.y_edge
|
|
1001
|
-
}, {
|
|
1002
|
-
enabled: true,
|
|
1003
|
-
threshold_px: ctx.options.snap_threshold_px
|
|
1004
|
-
});
|
|
1005
|
-
if (r.dx === 0 && r.dy === 0) return {
|
|
1006
|
-
plan,
|
|
1007
|
-
emit: r.guide ? { guide: r.guide } : void 0
|
|
1008
|
-
};
|
|
1009
|
-
if (eff.uniform) {
|
|
1010
|
-
const bbox = pbase.bbox;
|
|
1011
|
-
const new_Hx = eff.moving_corner.x + r.dx;
|
|
1012
|
-
const new_Hy = eff.moving_corner.y + r.dy;
|
|
1013
|
-
const sx_from_x = eff.mask.x_edge !== null && r.dx !== 0 && bbox.width !== 0 ? (new_Hx - eff.origin.x) / (eff.moving_corner.x - eff.origin.x) * eff.sx : null;
|
|
1014
|
-
const sy_from_y = eff.mask.y_edge !== null && r.dy !== 0 && bbox.height !== 0 ? (new_Hy - eff.origin.y) / (eff.moving_corner.y - eff.origin.y) * eff.sy : null;
|
|
1015
|
-
let s = eff.sx;
|
|
1016
|
-
if (sx_from_x !== null && sy_from_y !== null) s = Math.min(sx_from_x, sy_from_y);
|
|
1017
|
-
else if (sx_from_x !== null) s = sx_from_x;
|
|
1018
|
-
else if (sy_from_y !== null) s = sy_from_y;
|
|
1019
|
-
const Hx_base = corner_x_of(bbox, plan.direction);
|
|
1020
|
-
const Hy_base = corner_y_of(bbox, plan.direction);
|
|
1021
|
-
const target_Hx = eff.origin.x + (Hx_base - eff.origin.x) * s;
|
|
1022
|
-
const target_Hy = eff.origin.y + (Hy_base - eff.origin.y) * s;
|
|
1023
|
-
return {
|
|
1024
|
-
plan: {
|
|
1025
|
-
...plan,
|
|
1026
|
-
dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
|
|
1027
|
-
dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
|
|
1028
|
-
},
|
|
1029
|
-
emit: r.guide ? { guide: r.guide } : void 0
|
|
1030
|
-
};
|
|
1031
|
-
}
|
|
1032
|
-
return {
|
|
1033
|
-
plan: {
|
|
1034
|
-
...plan,
|
|
1035
|
-
dx: eff.mask.affects_x ? plan.dx + r.dx : plan.dx,
|
|
1036
|
-
dy: eff.mask.affects_y ? plan.dy + r.dy : plan.dy
|
|
1037
|
-
},
|
|
1038
|
-
emit: r.guide ? { guide: r.guide } : void 0
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
};
|
|
1042
|
-
/** Quantize the moving corner's *post-resize* position to integer
|
|
1043
|
-
* multiples of `pixel_grid_quantum` in own-frame space. Identity when
|
|
1044
|
-
* quantum is `null` / `<= 0` or the gesture is a no-op. */
|
|
1045
|
-
const stage_pixel_grid = {
|
|
1046
|
-
name: "pixel_grid",
|
|
1047
|
-
run(plan, ctx) {
|
|
1048
|
-
const q = ctx.options.pixel_grid_quantum;
|
|
1049
|
-
if (q === null || q <= 0) return { plan };
|
|
1050
|
-
const pbase = pipeline_baseline(plan);
|
|
1051
|
-
const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
|
|
1052
|
-
const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
|
|
1053
|
-
if (eff.no_op) return { plan };
|
|
1054
|
-
const target_Hx = eff.mask.affects_x ? Math.round(eff.moving_corner.x / q) * q : eff.moving_corner.x;
|
|
1055
|
-
const target_Hy = eff.mask.affects_y ? Math.round(eff.moving_corner.y / q) * q : eff.moving_corner.y;
|
|
1056
|
-
const bbox = pbase.bbox;
|
|
1057
|
-
const Hx_base = corner_x_of(bbox, plan.direction);
|
|
1058
|
-
const Hy_base = corner_y_of(bbox, plan.direction);
|
|
1059
|
-
return { plan: {
|
|
1060
|
-
...plan,
|
|
1061
|
-
dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
|
|
1062
|
-
dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
|
|
1063
|
-
} };
|
|
1064
|
-
}
|
|
1065
|
-
};
|
|
1066
|
-
function corner_x_of(r, dir) {
|
|
1067
|
-
switch (dir) {
|
|
1068
|
-
case "nw":
|
|
1069
|
-
case "w":
|
|
1070
|
-
case "sw": return r.x;
|
|
1071
|
-
case "ne":
|
|
1072
|
-
case "e":
|
|
1073
|
-
case "se": return r.x + r.width;
|
|
1074
|
-
case "n":
|
|
1075
|
-
case "s": return r.x + r.width / 2;
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
function corner_y_of(r, dir) {
|
|
1079
|
-
switch (dir) {
|
|
1080
|
-
case "nw":
|
|
1081
|
-
case "n":
|
|
1082
|
-
case "ne": return r.y;
|
|
1083
|
-
case "sw":
|
|
1084
|
-
case "s":
|
|
1085
|
-
case "se": return r.y + r.height;
|
|
1086
|
-
case "e":
|
|
1087
|
-
case "w": return r.y + r.height / 2;
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
/** Default stage list for HUD-driven resize gestures (drag).
|
|
1091
|
-
* No NUDGE / RPC analogs at v1 (no `commands.resize` per README). */
|
|
1092
|
-
const STAGES_DEFAULT = Object.freeze([
|
|
1093
|
-
stage_aspect_lock,
|
|
1094
|
-
stage_snap,
|
|
1095
|
-
stage_pixel_grid
|
|
1096
|
-
]);
|
|
1097
|
-
//#endregion
|
|
1098
|
-
//#region src/core/resize-pipeline/apply.ts
|
|
1099
|
-
function applyResizePlan(doc, plan, phase = "commit") {
|
|
1100
|
-
const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, plan.dx, plan.dy, false);
|
|
1101
|
-
const members = plan.members ?? [{
|
|
1102
|
-
id: plan.id,
|
|
1103
|
-
baseline: plan.baseline
|
|
1104
|
-
}];
|
|
1105
|
-
for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, f.sx, f.sy, f.origin, phase);
|
|
1106
|
-
}
|
|
1107
|
-
function revertResizePlan(doc, plan) {
|
|
1108
|
-
const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, 0, 0, false);
|
|
1109
|
-
const members = plan.members ?? [{
|
|
1110
|
-
id: plan.id,
|
|
1111
|
-
baseline: plan.baseline
|
|
1112
|
-
}];
|
|
1113
|
-
for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, 1, 1, f.origin, "preview");
|
|
1114
|
-
}
|
|
1115
|
-
/**
|
|
1116
|
-
* Synthesize a "group" baseline over an arbitrary union rect. The attrs
|
|
1117
|
-
* carrier is `rect`-kind so the pipeline math (snap / pixel-grid)
|
|
1118
|
-
* treats the group as free per-axis — per-member constraints (circle
|
|
1119
|
-
* uniform, text edge no-op) kick in at apply time against each
|
|
1120
|
-
* member's own captured baseline.
|
|
743
|
+
* `result` is the text that should remain — the typed text on commit, the
|
|
744
|
+
* original on cancel. "Empty" means zero-length: a space is authored
|
|
745
|
+
* content and is kept. The rule is unconditional — an empty result deletes
|
|
746
|
+
* the node however it got there (freshly placed and never typed, cleared by
|
|
747
|
+
* the author, or already empty on entry).
|
|
1121
748
|
*
|
|
1122
|
-
*
|
|
1123
|
-
* baseline directly so the per-element snap correction (`eff.uniform`
|
|
1124
|
-
* branch) fires correctly.
|
|
749
|
+
* See docs/wg/feat-svg-editor/text-tool.md.
|
|
1125
750
|
*/
|
|
1126
|
-
function
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
},
|
|
1134
|
-
attrs: {
|
|
1135
|
-
kind: "rect",
|
|
1136
|
-
x: union.x,
|
|
1137
|
-
y: union.y,
|
|
1138
|
-
w: union.width,
|
|
1139
|
-
h: union.height
|
|
1140
|
-
}
|
|
1141
|
-
};
|
|
1142
|
-
}
|
|
1143
|
-
//#endregion
|
|
1144
|
-
//#region src/core/resize-pipeline/orchestrator.ts
|
|
1145
|
-
const PROVIDER_ID = "svg-editor";
|
|
1146
|
-
/** West/north-anchor flips invert the corresponding world delta so a
|
|
1147
|
-
* positive value always grows the moving edge outward — the convention
|
|
1148
|
-
* `compute_resize_factors` consumes. */
|
|
1149
|
-
function sign_adjust(dir, dx_world, dy_world) {
|
|
1150
|
-
return {
|
|
1151
|
-
dx: dir === "w" || dir === "nw" || dir === "sw" ? -dx_world : dx_world,
|
|
1152
|
-
dy: dir === "n" || dir === "ne" || dir === "nw" ? -dy_world : dy_world
|
|
751
|
+
function resolve_text_exit(input) {
|
|
752
|
+
const is_empty = input.result.length === 0;
|
|
753
|
+
if (input.origin === "fresh") return is_empty ? { kind: "discard_insert" } : { kind: "commit_insert" };
|
|
754
|
+
if (is_empty) return { kind: "remove" };
|
|
755
|
+
if (input.result !== input.original) return {
|
|
756
|
+
kind: "set_text",
|
|
757
|
+
value: input.result
|
|
1153
758
|
};
|
|
759
|
+
return { kind: "noop" };
|
|
1154
760
|
}
|
|
1155
|
-
/** Stable, order-independent key for an id set — used by `is_active_for`
|
|
1156
|
-
* to decide whether the current session targets the same group. */
|
|
1157
|
-
function ids_key(ids) {
|
|
1158
|
-
return [...ids].sort().join("\0");
|
|
1159
|
-
}
|
|
1160
|
-
var ResizeOrchestrator = class {
|
|
1161
|
-
constructor(deps) {
|
|
1162
|
-
this.deps = deps;
|
|
1163
|
-
this.active = null;
|
|
1164
|
-
this._last_guides = [];
|
|
1165
|
-
}
|
|
1166
|
-
/** Guides emitted by the most recent pipeline run. Cleared on
|
|
1167
|
-
* cancel/dispose. */
|
|
1168
|
-
get last_guides() {
|
|
1169
|
-
return this._last_guides;
|
|
1170
|
-
}
|
|
1171
|
-
has_active_session() {
|
|
1172
|
-
return this.active !== null;
|
|
1173
|
-
}
|
|
1174
|
-
/** Is the gesture currently targeting `ids` with `direction`? Used by
|
|
1175
|
-
* the HUD dispatch to decide whether to reset the session on a new
|
|
1176
|
-
* handle / target. Order-independent. */
|
|
1177
|
-
is_active_for(ids, direction) {
|
|
1178
|
-
return this.active !== null && this.active.direction === direction && this.active.ids_key === ids_key(ids);
|
|
1179
|
-
}
|
|
1180
|
-
/** Per-frame drive. Opens a session lazily on the first call. The
|
|
1181
|
-
* HUD passes its gesture-target rect dimensions in **world space**;
|
|
1182
|
-
* the orchestrator derives the signed world-frame delta against its
|
|
1183
|
-
* captured `baseline.bbox`. The DOM adapter is responsible for the
|
|
1184
|
-
* CSS-px → world conversion at the intent boundary. */
|
|
1185
|
-
drive(input, modifiers, opts) {
|
|
1186
|
-
if (input.ids.length === 0) return null;
|
|
1187
|
-
const doc = this.deps.get_doc();
|
|
1188
|
-
for (const id of input.ids) if (!require_insertions.is_resizable_node(doc, id)) return null;
|
|
1189
|
-
const key = ids_key(input.ids);
|
|
1190
|
-
if (this.active && (this.active.ids_key !== key || this.active.direction !== input.direction)) {
|
|
1191
|
-
this.active.preview.discard();
|
|
1192
|
-
this.dispose_session();
|
|
1193
|
-
}
|
|
1194
|
-
if (this.active === null) this.active = this.open(input.ids, input.direction, opts.snap, opts.label ?? "resize");
|
|
1195
|
-
const session = this.active;
|
|
1196
|
-
const bbox = session.baseline.bbox;
|
|
1197
|
-
const dx_world = input.target_width - bbox.width;
|
|
1198
|
-
const dy_world = input.target_height - bbox.height;
|
|
1199
|
-
const d = sign_adjust(input.direction, dx_world, dy_world);
|
|
1200
|
-
const stages = opts.stages ?? STAGES_DEFAULT;
|
|
1201
|
-
const result = this.run_pass(session, d.dx, d.dy, modifiers, stages);
|
|
1202
|
-
session.last_dx = d.dx;
|
|
1203
|
-
session.last_dy = d.dy;
|
|
1204
|
-
session.last_stages = stages;
|
|
1205
|
-
this.write_preview(session, result.plan, opts.phase);
|
|
1206
|
-
if (opts.phase === "commit") {
|
|
1207
|
-
session.preview.commit();
|
|
1208
|
-
this.dispose_session();
|
|
1209
|
-
}
|
|
1210
|
-
return result;
|
|
1211
|
-
}
|
|
1212
|
-
/** Re-run the current preview frame with new modifiers. */
|
|
1213
|
-
redrive_modifiers(modifiers) {
|
|
1214
|
-
if (!this.active) return null;
|
|
1215
|
-
const session = this.active;
|
|
1216
|
-
const result = this.run_pass(session, session.last_dx, session.last_dy, modifiers, session.last_stages);
|
|
1217
|
-
this.write_preview(session, result.plan, "preview");
|
|
1218
|
-
return result;
|
|
1219
|
-
}
|
|
1220
|
-
cancel() {
|
|
1221
|
-
if (!this.active) return;
|
|
1222
|
-
this.active.preview.discard();
|
|
1223
|
-
this.dispose_session();
|
|
1224
|
-
}
|
|
1225
|
-
run_pass(session, dx, dy, modifiers, stages) {
|
|
1226
|
-
const result = run_resize_pipeline({
|
|
1227
|
-
id: session.primary_id,
|
|
1228
|
-
baseline: session.baseline,
|
|
1229
|
-
members: session.members,
|
|
1230
|
-
direction: session.direction,
|
|
1231
|
-
dx,
|
|
1232
|
-
dy
|
|
1233
|
-
}, stages, {
|
|
1234
|
-
input: {
|
|
1235
|
-
id: session.primary_id,
|
|
1236
|
-
direction: session.direction,
|
|
1237
|
-
dx,
|
|
1238
|
-
dy
|
|
1239
|
-
},
|
|
1240
|
-
modifiers,
|
|
1241
|
-
options: this.deps.options(),
|
|
1242
|
-
snap_session: session.snap
|
|
1243
|
-
});
|
|
1244
|
-
this._last_guides = result.guides;
|
|
1245
|
-
return result;
|
|
1246
|
-
}
|
|
1247
|
-
open(ids, direction, snap, label) {
|
|
1248
|
-
const doc = this.deps.get_doc();
|
|
1249
|
-
const members = ids.map((id) => ({
|
|
1250
|
-
id,
|
|
1251
|
-
baseline: require_insertions.capture_resize_baseline(doc, id, this.deps.bbox_world(id))
|
|
1252
|
-
}));
|
|
1253
|
-
const baseline = members.length === 1 ? members[0].baseline : synthesize_group_baseline(_grida_cmath.default.rect.union(members.map((m) => m.baseline.bbox)));
|
|
1254
|
-
return {
|
|
1255
|
-
ids_key: ids_key(ids),
|
|
1256
|
-
primary_id: members[0].id,
|
|
1257
|
-
direction,
|
|
1258
|
-
members,
|
|
1259
|
-
baseline,
|
|
1260
|
-
snap: snap ? this.deps.open_snap(ids) : null,
|
|
1261
|
-
preview: this.deps.open_preview(label),
|
|
1262
|
-
last_dx: 0,
|
|
1263
|
-
last_dy: 0,
|
|
1264
|
-
last_stages: STAGES_DEFAULT
|
|
1265
|
-
};
|
|
1266
|
-
}
|
|
1267
|
-
write_preview(session, plan, phase) {
|
|
1268
|
-
const doc = this.deps.get_doc();
|
|
1269
|
-
const emit = this.deps.emit;
|
|
1270
|
-
session.preview.set({
|
|
1271
|
-
providerId: PROVIDER_ID,
|
|
1272
|
-
apply: () => {
|
|
1273
|
-
applyResizePlan(doc, plan, phase);
|
|
1274
|
-
emit();
|
|
1275
|
-
},
|
|
1276
|
-
revert: () => {
|
|
1277
|
-
revertResizePlan(doc, plan);
|
|
1278
|
-
emit();
|
|
1279
|
-
}
|
|
1280
|
-
});
|
|
1281
|
-
}
|
|
1282
|
-
dispose_session() {
|
|
1283
|
-
if (!this.active) return;
|
|
1284
|
-
this.active.snap?.dispose();
|
|
1285
|
-
this.active = null;
|
|
1286
|
-
this._last_guides = [];
|
|
1287
|
-
}
|
|
1288
|
-
};
|
|
1289
761
|
//#endregion
|
|
1290
762
|
//#region src/gestures/gestures.ts
|
|
1291
763
|
/**
|
|
@@ -1421,7 +893,7 @@ const DEFAULT_GESTURE_BINDINGS = [
|
|
|
1421
893
|
WHEEL_PAN_ZOOM,
|
|
1422
894
|
{
|
|
1423
895
|
id: "space-drag-pan",
|
|
1424
|
-
install({ container, camera }) {
|
|
896
|
+
install({ container, camera, is_attended }) {
|
|
1425
897
|
let space_held = false;
|
|
1426
898
|
let prev_cursor = null;
|
|
1427
899
|
const set_cursor = (next) => {
|
|
@@ -1431,7 +903,8 @@ const DEFAULT_GESTURE_BINDINGS = [
|
|
|
1431
903
|
};
|
|
1432
904
|
const on_keydown = (e) => {
|
|
1433
905
|
if (e.code !== "Space" || e.repeat) return;
|
|
1434
|
-
if (
|
|
906
|
+
if (require_model.is_text_input_focused()) return;
|
|
907
|
+
if (!is_attended()) return;
|
|
1435
908
|
space_held = true;
|
|
1436
909
|
set_cursor("grab");
|
|
1437
910
|
e.preventDefault();
|
|
@@ -1484,12 +957,11 @@ const DEFAULT_GESTURE_BINDINGS = [
|
|
|
1484
957
|
},
|
|
1485
958
|
{
|
|
1486
959
|
id: "keyboard-zoom",
|
|
1487
|
-
install({ container, camera }) {
|
|
960
|
+
install({ container, camera, is_attended }) {
|
|
1488
961
|
const owner_doc = container.ownerDocument;
|
|
1489
962
|
const on_keydown = (e) => {
|
|
1490
|
-
|
|
1491
|
-
if (
|
|
1492
|
-
if (require_insertions.is_text_input_focused()) return;
|
|
963
|
+
if (!is_attended()) return;
|
|
964
|
+
if (require_model.is_text_input_focused()) return;
|
|
1493
965
|
const mod = e.metaKey || e.ctrlKey;
|
|
1494
966
|
if (e.shiftKey && !mod && (e.code === "Digit0" || e.code === "Numpad0")) {
|
|
1495
967
|
camera.reset();
|
|
@@ -1518,6 +990,39 @@ function applyDefaultGestures(gestures) {
|
|
|
1518
990
|
for (const b of DEFAULT_GESTURE_BINDINGS) gestures.bind(b);
|
|
1519
991
|
}
|
|
1520
992
|
//#endregion
|
|
993
|
+
//#region src/util/attention.ts
|
|
994
|
+
/**
|
|
995
|
+
* Install pointer-tracking listeners on `container` and return the
|
|
996
|
+
* read-side handle. The tracker is owned by the surface and disposed
|
|
997
|
+
* alongside it; gesture bindings that need to consult it receive the
|
|
998
|
+
* read-only `is_attended` predicate through `GestureContext`.
|
|
999
|
+
*/
|
|
1000
|
+
function create_attention_tracker(container) {
|
|
1001
|
+
let pointer_over = false;
|
|
1002
|
+
const on_enter = () => {
|
|
1003
|
+
pointer_over = true;
|
|
1004
|
+
};
|
|
1005
|
+
const on_leave = () => {
|
|
1006
|
+
pointer_over = false;
|
|
1007
|
+
};
|
|
1008
|
+
container.addEventListener("pointerenter", on_enter);
|
|
1009
|
+
container.addEventListener("pointerleave", on_leave);
|
|
1010
|
+
const is_attended = () => {
|
|
1011
|
+
const owner = container.ownerDocument;
|
|
1012
|
+
if (!owner) return pointer_over;
|
|
1013
|
+
const active = owner.activeElement;
|
|
1014
|
+
if (active && active !== owner.body && container.contains(active)) return true;
|
|
1015
|
+
return pointer_over;
|
|
1016
|
+
};
|
|
1017
|
+
return {
|
|
1018
|
+
is_attended,
|
|
1019
|
+
dispose: () => {
|
|
1020
|
+
container.removeEventListener("pointerenter", on_enter);
|
|
1021
|
+
container.removeEventListener("pointerleave", on_leave);
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
//#endregion
|
|
1521
1026
|
//#region src/text-surface.ts
|
|
1522
1027
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
1523
1028
|
const XML_NS = "http://www.w3.org/XML/1998/namespace";
|
|
@@ -1677,6 +1182,423 @@ var SvgTextSurface = class {
|
|
|
1677
1182
|
}
|
|
1678
1183
|
};
|
|
1679
1184
|
//#endregion
|
|
1185
|
+
//#region src/core/vector-edit/session.ts
|
|
1186
|
+
function tangents_equal(a, b) {
|
|
1187
|
+
return a[0] === b[0] && a[1] === b[1];
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Shallow equality over the three sub-selection arrays. Order-sensitive
|
|
1191
|
+
* (mirrors how the host stores them). Used by the orchestrator to skip
|
|
1192
|
+
* pushing a no-op undo entry when a selection handler resolves to the
|
|
1193
|
+
* same state — e.g. clicking an already-selected vertex in `replace`
|
|
1194
|
+
* mode.
|
|
1195
|
+
*/
|
|
1196
|
+
function sub_selection_equal(a, b) {
|
|
1197
|
+
if (a === b) return true;
|
|
1198
|
+
if (a.vertices.length !== b.vertices.length) return false;
|
|
1199
|
+
if (a.segments.length !== b.segments.length) return false;
|
|
1200
|
+
if (a.tangents.length !== b.tangents.length) return false;
|
|
1201
|
+
for (let i = 0; i < a.vertices.length; i++) if (a.vertices[i] !== b.vertices[i]) return false;
|
|
1202
|
+
for (let i = 0; i < a.segments.length; i++) if (a.segments[i] !== b.segments[i]) return false;
|
|
1203
|
+
for (let i = 0; i < a.tangents.length; i++) if (!tangents_equal(a.tangents[i], b.tangents[i])) return false;
|
|
1204
|
+
return true;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Host-side state for vector content-edit (vertex / segment / tangent
|
|
1208
|
+
* gestures) on the supported source tags. "Vector" here names the
|
|
1209
|
+
* editing mode — NOT a `vn.VectorNetwork` wrapper. The session holds
|
|
1210
|
+
* the source tag's authored attrs plus a path-form session-d, and
|
|
1211
|
+
* delegates geometry to {@link PathModel} via the apply.ts shim.
|
|
1212
|
+
*/
|
|
1213
|
+
var VectorEditSession = class {
|
|
1214
|
+
constructor(node_id, source, session_d) {
|
|
1215
|
+
this.node_id = node_id;
|
|
1216
|
+
this._source = source;
|
|
1217
|
+
this._source_before_promotion = null;
|
|
1218
|
+
this._session_d = session_d;
|
|
1219
|
+
this._last_seen_d = session_d;
|
|
1220
|
+
this._selected_vertices = [];
|
|
1221
|
+
this._selected_segments = [];
|
|
1222
|
+
this._selected_tangents = [];
|
|
1223
|
+
this._hovered_control = null;
|
|
1224
|
+
}
|
|
1225
|
+
/** Source tag the session currently projects through. See `_source`. */
|
|
1226
|
+
get source() {
|
|
1227
|
+
return this._source;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Flip the source to `path` after the underlying element was promoted
|
|
1231
|
+
* (rect / circle / ellipse → `<path>`). Idempotent: a second call while
|
|
1232
|
+
* already promoted does nothing, so the pre-promotion source captured by
|
|
1233
|
+
* the first flip is never clobbered.
|
|
1234
|
+
*/
|
|
1235
|
+
promote_source_to_path() {
|
|
1236
|
+
if (this._source_before_promotion !== null) return;
|
|
1237
|
+
if (this._source.kind === "path") return;
|
|
1238
|
+
this._source_before_promotion = this._source;
|
|
1239
|
+
this._source = {
|
|
1240
|
+
kind: "path",
|
|
1241
|
+
d: this._session_d
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
/** Reverse a {@link promote_source_to_path} (gesture undo). No-op if the
|
|
1245
|
+
* source was never promoted. */
|
|
1246
|
+
restore_source() {
|
|
1247
|
+
if (this._source_before_promotion === null) return;
|
|
1248
|
+
this._source = this._source_before_promotion;
|
|
1249
|
+
this._source_before_promotion = null;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Re-sync the source to the document's current tag, outright. Unlike
|
|
1253
|
+
* {@link promote_source_to_path} / {@link restore_source} (which manage a
|
|
1254
|
+
* single primitive→path flip within one gesture), this sets the source to
|
|
1255
|
+
* an authoritative value derived from the live document and clears the
|
|
1256
|
+
* promotion bookkeeping.
|
|
1257
|
+
*
|
|
1258
|
+
* The host calls this when an undo/redo re-types the node out from under a
|
|
1259
|
+
* *different* live session object than the one that performed the original
|
|
1260
|
+
* flip (exit + undo-exit creates a fresh session; the captured session's
|
|
1261
|
+
* `restore_source` then no-ops). Without it the live session could keep
|
|
1262
|
+
* `source.kind === "path"` while the node is back to a primitive, and the
|
|
1263
|
+
* next gesture would write a stray `d` onto the native tag. Re-deriving
|
|
1264
|
+
* from the document keeps the live session authoritative.
|
|
1265
|
+
*/
|
|
1266
|
+
sync_source(source) {
|
|
1267
|
+
this._source = source;
|
|
1268
|
+
this._source_before_promotion = null;
|
|
1269
|
+
}
|
|
1270
|
+
/** The session's current PathModel-form `d`. Gesture handlers read
|
|
1271
|
+
* this instead of `doc.get_attr(node_id, "d")` so they stay tag-
|
|
1272
|
+
* oblivious (non-path sources have no `d` on the document). */
|
|
1273
|
+
get current_d() {
|
|
1274
|
+
return this._session_d;
|
|
1275
|
+
}
|
|
1276
|
+
/** Update the session's view after a write produced `next_d`. Caller
|
|
1277
|
+
* is `apply_session_d` (or the gesture handler that called it). */
|
|
1278
|
+
update_session_d(next_d) {
|
|
1279
|
+
this._session_d = next_d;
|
|
1280
|
+
}
|
|
1281
|
+
get last_seen_d() {
|
|
1282
|
+
return this._last_seen_d;
|
|
1283
|
+
}
|
|
1284
|
+
get selected_vertices() {
|
|
1285
|
+
return this._selected_vertices;
|
|
1286
|
+
}
|
|
1287
|
+
get selected_segments() {
|
|
1288
|
+
return this._selected_segments;
|
|
1289
|
+
}
|
|
1290
|
+
get selected_tangents() {
|
|
1291
|
+
return this._selected_tangents;
|
|
1292
|
+
}
|
|
1293
|
+
get hovered_control() {
|
|
1294
|
+
return this._hovered_control;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Record that the host's most recent gesture write produced `d`.
|
|
1298
|
+
* Updates both the session-d (the in-session canonical form) and the
|
|
1299
|
+
* last-seen mark. The next state-change tick uses last_seen to
|
|
1300
|
+
* distinguish "we wrote this" from "the document changed under us".
|
|
1301
|
+
*/
|
|
1302
|
+
mark_seen(d) {
|
|
1303
|
+
this._session_d = d;
|
|
1304
|
+
this._last_seen_d = d;
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* The session's response to a detected external mutation of `d`
|
|
1308
|
+
* (undo / redo / programmatic / collab). Atomically (a) advances
|
|
1309
|
+
* `last_seen_d` to the now-current value and (b) drops sub-selection
|
|
1310
|
+
* — selection indices reference vertices and segments by ordinal
|
|
1311
|
+
* position, and an external mutation may have shifted or removed
|
|
1312
|
+
* them.
|
|
1313
|
+
*
|
|
1314
|
+
* Exposed as a single method so callers cannot get the two halves
|
|
1315
|
+
* out of order. Doing `clear_selection` without `mark_seen` would
|
|
1316
|
+
* leave us "stuck dirty" — the next tick would reconcile again.
|
|
1317
|
+
* Doing `mark_seen` without `clear_selection` would leave stale
|
|
1318
|
+
* indices pointing into a geometry that no longer matches.
|
|
1319
|
+
*/
|
|
1320
|
+
reconcile_after_external_mutation(d) {
|
|
1321
|
+
this.mark_seen(d);
|
|
1322
|
+
this.clear_selection();
|
|
1323
|
+
}
|
|
1324
|
+
select_vertex(index, mode) {
|
|
1325
|
+
switch (mode) {
|
|
1326
|
+
case "replace":
|
|
1327
|
+
this._selected_vertices = [index];
|
|
1328
|
+
break;
|
|
1329
|
+
case "add":
|
|
1330
|
+
if (!this._selected_vertices.includes(index)) this._selected_vertices = [...this._selected_vertices, index];
|
|
1331
|
+
break;
|
|
1332
|
+
case "toggle":
|
|
1333
|
+
this._selected_vertices = this._selected_vertices.includes(index) ? this._selected_vertices.filter((v) => v !== index) : [...this._selected_vertices, index];
|
|
1334
|
+
break;
|
|
1335
|
+
}
|
|
1336
|
+
if (mode === "replace") {
|
|
1337
|
+
this._selected_segments = [];
|
|
1338
|
+
this._selected_tangents = [];
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
select_segment(index, mode) {
|
|
1342
|
+
switch (mode) {
|
|
1343
|
+
case "replace":
|
|
1344
|
+
this._selected_segments = [index];
|
|
1345
|
+
break;
|
|
1346
|
+
case "add":
|
|
1347
|
+
if (!this._selected_segments.includes(index)) this._selected_segments = [...this._selected_segments, index];
|
|
1348
|
+
break;
|
|
1349
|
+
case "toggle":
|
|
1350
|
+
this._selected_segments = this._selected_segments.includes(index) ? this._selected_segments.filter((s) => s !== index) : [...this._selected_segments, index];
|
|
1351
|
+
break;
|
|
1352
|
+
}
|
|
1353
|
+
if (mode === "replace") {
|
|
1354
|
+
this._selected_vertices = [];
|
|
1355
|
+
this._selected_tangents = [];
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
select_tangent(ref, mode) {
|
|
1359
|
+
const has = this._selected_tangents.some((t) => tangents_equal(t, ref));
|
|
1360
|
+
switch (mode) {
|
|
1361
|
+
case "replace":
|
|
1362
|
+
this._selected_tangents = [ref];
|
|
1363
|
+
break;
|
|
1364
|
+
case "add":
|
|
1365
|
+
if (!has) this._selected_tangents = [...this._selected_tangents, ref];
|
|
1366
|
+
break;
|
|
1367
|
+
case "toggle":
|
|
1368
|
+
this._selected_tangents = has ? this._selected_tangents.filter((t) => !tangents_equal(t, ref)) : [...this._selected_tangents, ref];
|
|
1369
|
+
break;
|
|
1370
|
+
}
|
|
1371
|
+
if (mode === "replace") {
|
|
1372
|
+
this._selected_vertices = [];
|
|
1373
|
+
this._selected_segments = [];
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Replace the entire sub-selection at once. Useful for marquee /
|
|
1378
|
+
* lasso results, which compute the full set up-front.
|
|
1379
|
+
*/
|
|
1380
|
+
set_selection(next) {
|
|
1381
|
+
this._selected_vertices = [...next.vertices];
|
|
1382
|
+
this._selected_segments = [...next.segments];
|
|
1383
|
+
this._selected_tangents = next.tangents.map((t) => [t[0], t[1]]);
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Capture the current sub-selection as a frozen triple. The
|
|
1387
|
+
* orchestrator closes over snapshots in gesture deltas (so undo
|
|
1388
|
+
* restores selection alongside geometry) and in standalone selection
|
|
1389
|
+
* deltas (so a click on a vertex is itself undoable).
|
|
1390
|
+
*
|
|
1391
|
+
* Returned arrays are fresh copies — safe to retain across
|
|
1392
|
+
* subsequent mutations of the session.
|
|
1393
|
+
*/
|
|
1394
|
+
snapshot_selection() {
|
|
1395
|
+
return Object.freeze({
|
|
1396
|
+
vertices: Object.freeze([...this._selected_vertices]),
|
|
1397
|
+
segments: Object.freeze([...this._selected_segments]),
|
|
1398
|
+
tangents: Object.freeze(this._selected_tangents.map((t) => [t[0], t[1]]))
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Restore a previously-captured sub-selection. Counterpart to
|
|
1403
|
+
* {@link snapshot_selection}. Equivalent to calling
|
|
1404
|
+
* {@link set_selection} with the snapshot's contents.
|
|
1405
|
+
*/
|
|
1406
|
+
restore_selection(snap) {
|
|
1407
|
+
this.set_selection(snap);
|
|
1408
|
+
}
|
|
1409
|
+
clear_selection() {
|
|
1410
|
+
if (this._selected_vertices.length === 0 && this._selected_segments.length === 0 && this._selected_tangents.length === 0) return;
|
|
1411
|
+
this._selected_vertices = [];
|
|
1412
|
+
this._selected_segments = [];
|
|
1413
|
+
this._selected_tangents = [];
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
//#endregion
|
|
1417
|
+
//#region src/core/vector-edit/apply.ts
|
|
1418
|
+
/**
|
|
1419
|
+
* Build the in-session path-form `d` ("session-d") for a freshly-
|
|
1420
|
+
* entered vector-edit. For `<path>` this is the verbatim authored
|
|
1421
|
+
* string; for the two vertex-chain tags we route through
|
|
1422
|
+
* `vn.fromPolyline` / `vn.fromPolygon` and emit via `vn.toSVGPathData`
|
|
1423
|
+
* — same zero-tangent `M`/`L` sequence the gesture handlers'
|
|
1424
|
+
* `PathModel.toSvgPathD()` will produce on subsequent commits.
|
|
1425
|
+
*
|
|
1426
|
+
* The returned string is internal to the session — it is never written
|
|
1427
|
+
* to the document on its own. Native-attr writeback happens through
|
|
1428
|
+
* {@link apply_session_d} (which calls {@link PathModel.toNativeAttrs}
|
|
1429
|
+
* to project the path-form geometry back to source-tag attrs).
|
|
1430
|
+
*/
|
|
1431
|
+
function source_to_session_d(source) {
|
|
1432
|
+
switch (source.kind) {
|
|
1433
|
+
case "path": return source.d;
|
|
1434
|
+
case "line": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolyline([[source.x1, source.y1], [source.x2, source.y2]]));
|
|
1435
|
+
case "polyline": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolyline(source.points.map((p) => [p[0], p[1]])));
|
|
1436
|
+
case "polygon": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromPolygon(source.points.map((p) => [p[0], p[1]])));
|
|
1437
|
+
case "circle": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromEllipse({
|
|
1438
|
+
x: source.cx - source.r,
|
|
1439
|
+
y: source.cy - source.r,
|
|
1440
|
+
width: source.r * 2,
|
|
1441
|
+
height: source.r * 2
|
|
1442
|
+
}));
|
|
1443
|
+
case "ellipse": return _grida_vn.default.toSVGPathData(_grida_vn.default.fromEllipse({
|
|
1444
|
+
x: source.cx - source.rx,
|
|
1445
|
+
y: source.cy - source.ry,
|
|
1446
|
+
width: source.rx * 2,
|
|
1447
|
+
height: source.ry * 2
|
|
1448
|
+
}));
|
|
1449
|
+
case "rect": return _grida_svg_pathdata.SVGShapes.createRect(source.x, source.y, source.width, source.height, source.rx, source.ry).encode();
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Native-attribute writeback. Given a new path-data `d` from a gesture,
|
|
1454
|
+
* project it back into the source tag's native attrs and write those —
|
|
1455
|
+
* `<path>` takes `d` directly; `<line>` takes `x1/y1/x2/y2`;
|
|
1456
|
+
* `<polyline>` / `<polygon>` take `points`.
|
|
1457
|
+
*
|
|
1458
|
+
* Returns `true` if the geometry was written natively. Returns `false`
|
|
1459
|
+
* when the source tag cannot express the geometry — a curve was introduced
|
|
1460
|
+
* or the topology left the tag's canonical form, OR the source is a
|
|
1461
|
+
* geometry primitive (rect / circle / ellipse) which has no native vector
|
|
1462
|
+
* form at all. A `false` return is the re-type-to-`<path>` signal; the
|
|
1463
|
+
* caller ({@link vector_apply}) handles it. This function never re-types.
|
|
1464
|
+
*
|
|
1465
|
+
* Symmetric across apply / revert: callers use it for both the in-flight
|
|
1466
|
+
* write and the undo-revert (both are just "set the geometry to this d").
|
|
1467
|
+
*/
|
|
1468
|
+
function apply_session_d(doc, node_id, source, d) {
|
|
1469
|
+
if (source.kind === "path") {
|
|
1470
|
+
doc.set_attr(node_id, "d", d);
|
|
1471
|
+
return true;
|
|
1472
|
+
}
|
|
1473
|
+
const native = require_model.PathModel.fromSvgPathD(d).toNativeAttrs(source.kind);
|
|
1474
|
+
if (native === null) return false;
|
|
1475
|
+
if (native.kind === "line") {
|
|
1476
|
+
doc.set_attr(node_id, "x1", String(native.x1));
|
|
1477
|
+
doc.set_attr(node_id, "y1", String(native.y1));
|
|
1478
|
+
doc.set_attr(node_id, "x2", String(native.x2));
|
|
1479
|
+
doc.set_attr(node_id, "y2", String(native.y2));
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
const points = native.points.map((p) => `${p[0]},${p[1]}`).join(" ");
|
|
1483
|
+
doc.set_attr(node_id, "points", points);
|
|
1484
|
+
return true;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Session-aware geometry write — the single commit chokepoint the DOM
|
|
1488
|
+
* gesture handlers call so re-typing stays in one place rather than being
|
|
1489
|
+
* reimplemented per gesture. One uniform rule across every source:
|
|
1490
|
+
*
|
|
1491
|
+
* 1. Try native writeback ({@link apply_session_d}). For `<path>` and for
|
|
1492
|
+
* a vertex tag (`line` / `polyline` / `polygon`) whose edit still fits
|
|
1493
|
+
* its native form, this writes and we're done — the element keeps its
|
|
1494
|
+
* tag.
|
|
1495
|
+
* 2. If native writeback refused (a curve was introduced, the topology
|
|
1496
|
+
* escaped the canonical chain, or the source is a geometry primitive
|
|
1497
|
+
* with no native form), re-type the element to `<path>` via
|
|
1498
|
+
* {@link SvgDocument.retype_to_path} and flip the session source to
|
|
1499
|
+
* `path` (so every downstream reader — overlay, gates, the
|
|
1500
|
+
* external-mutation reconciler — behaves correctly).
|
|
1501
|
+
*
|
|
1502
|
+
* Returns the {@link RetypeRecord} token iff this call performed a re-type
|
|
1503
|
+
* (so the caller can pair it with the edit in one history bracket and hand
|
|
1504
|
+
* it to {@link vector_revert} on undo); otherwise `null`.
|
|
1505
|
+
*/
|
|
1506
|
+
function vector_apply(doc, session, d) {
|
|
1507
|
+
if (apply_session_d(doc, session.node_id, session.source, d)) return null;
|
|
1508
|
+
const token = doc.retype_to_path(session.node_id, d);
|
|
1509
|
+
if (token) {
|
|
1510
|
+
session.promote_source_to_path();
|
|
1511
|
+
return token;
|
|
1512
|
+
}
|
|
1513
|
+
doc.set_attr(session.node_id, "d", d);
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Counterpart to {@link vector_apply}. If this gesture re-typed the element
|
|
1518
|
+
* (a non-null `promotion` token), restore the original tag/attrs and the
|
|
1519
|
+
* session source — the re-type and the edit undo as one step. Otherwise
|
|
1520
|
+
* re-write the baseline geometry natively; for a geometry primitive that
|
|
1521
|
+
* never re-typed, {@link apply_session_d} writes nothing (a correct no-op).
|
|
1522
|
+
*/
|
|
1523
|
+
function vector_revert(doc, session, baseline_d, promotion) {
|
|
1524
|
+
if (promotion) {
|
|
1525
|
+
doc.revert_retype(session.node_id, promotion);
|
|
1526
|
+
session.restore_source();
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
apply_session_d(doc, session.node_id, session.source, baseline_d);
|
|
1530
|
+
}
|
|
1531
|
+
//#endregion
|
|
1532
|
+
//#region src/core/vector-edit/marquee.ts
|
|
1533
|
+
let marquee;
|
|
1534
|
+
(function(_marquee) {
|
|
1535
|
+
function points_in_rect(candidates, rect) {
|
|
1536
|
+
const hits = [];
|
|
1537
|
+
for (const c of candidates) if (_grida_cmath.default.rect.containsPoint(rect, c.pos)) hits.push(c.key);
|
|
1538
|
+
return hits;
|
|
1539
|
+
}
|
|
1540
|
+
_marquee.points_in_rect = points_in_rect;
|
|
1541
|
+
function points_in_polygon(candidates, polygon) {
|
|
1542
|
+
const hits = [];
|
|
1543
|
+
const poly = polygon;
|
|
1544
|
+
for (const c of candidates) if (_grida_cmath.default.polygon.pointInPolygon(c.pos, poly)) hits.push(c.key);
|
|
1545
|
+
return hits;
|
|
1546
|
+
}
|
|
1547
|
+
_marquee.points_in_polygon = points_in_polygon;
|
|
1548
|
+
function subpath_select_candidates(model, selection, to_doc = identity_proj) {
|
|
1549
|
+
const snap = model.snapshot();
|
|
1550
|
+
const vertices = snap.vertices.map((v, i) => ({
|
|
1551
|
+
key: i,
|
|
1552
|
+
pos: to_doc(v)
|
|
1553
|
+
}));
|
|
1554
|
+
const neigh_set = new Set(model.neighbouringVertices(selection));
|
|
1555
|
+
const tangents = [];
|
|
1556
|
+
for (let si = 0; si < snap.segments.length; si++) {
|
|
1557
|
+
const s = snap.segments[si];
|
|
1558
|
+
if (neigh_set.has(s.a)) {
|
|
1559
|
+
const va = snap.vertices[s.a];
|
|
1560
|
+
tangents.push({
|
|
1561
|
+
key: [s.a, 0],
|
|
1562
|
+
pos: to_doc([va[0] + s.ta[0], va[1] + s.ta[1]])
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
if (neigh_set.has(s.b)) {
|
|
1566
|
+
const vb = snap.vertices[s.b];
|
|
1567
|
+
tangents.push({
|
|
1568
|
+
key: [s.b, 1],
|
|
1569
|
+
pos: to_doc([vb[0] + s.tb[0], vb[1] + s.tb[1]])
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
return {
|
|
1574
|
+
vertices,
|
|
1575
|
+
tangents,
|
|
1576
|
+
segments: Array.from({ length: snap.segments.length }, (_, i) => i)
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
_marquee.subpath_select_candidates = subpath_select_candidates;
|
|
1580
|
+
function identity_proj(p) {
|
|
1581
|
+
return p;
|
|
1582
|
+
}
|
|
1583
|
+
function merge_subpath_hits(prev, hits, additive) {
|
|
1584
|
+
if (!additive) return {
|
|
1585
|
+
vertices: [...hits.vertices],
|
|
1586
|
+
segments: [...hits.segments],
|
|
1587
|
+
tangents: hits.tangents.map((t) => [t[0], t[1]])
|
|
1588
|
+
};
|
|
1589
|
+
const vertices = Array.from(new Set([...prev.vertices, ...hits.vertices]));
|
|
1590
|
+
const segments = Array.from(new Set([...prev.segments, ...hits.segments]));
|
|
1591
|
+
const tangents = prev.tangents.map((t) => [t[0], t[1]]);
|
|
1592
|
+
for (const t of hits.tangents) if (!tangents.some((x) => x[0] === t[0] && x[1] === t[1])) tangents.push(t);
|
|
1593
|
+
return {
|
|
1594
|
+
vertices,
|
|
1595
|
+
segments,
|
|
1596
|
+
tangents
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
_marquee.merge_subpath_hits = merge_subpath_hits;
|
|
1600
|
+
})(marquee || (marquee = {}));
|
|
1601
|
+
//#endregion
|
|
1680
1602
|
//#region src/dom.ts
|
|
1681
1603
|
/** Stamped on every rendered SVG element by `render()` so external
|
|
1682
1604
|
* tooling (host inspectors, the layers panel, snapshot tests) can map
|
|
@@ -1758,14 +1680,20 @@ var DomSurface = class DomSurface {
|
|
|
1758
1680
|
this.text_edit = null;
|
|
1759
1681
|
this.text_edit_target = null;
|
|
1760
1682
|
this.text_edit_original = "";
|
|
1761
|
-
this.
|
|
1683
|
+
this.pending_text_insert = null;
|
|
1684
|
+
this.vector_edit = null;
|
|
1685
|
+
this.vector_edit_region_baseline = null;
|
|
1686
|
+
this.current_tool = require_model.TOOL_CURSOR;
|
|
1762
1687
|
this.pending_insert = null;
|
|
1763
1688
|
this.editor_hover_internal = null;
|
|
1764
1689
|
this.container = options.container;
|
|
1765
1690
|
const container = this.container;
|
|
1766
1691
|
this.fit_on_attach = options.fit === true;
|
|
1692
|
+
this.attention = create_attention_tracker(container);
|
|
1693
|
+
this.teardown.push(() => this.attention.dispose());
|
|
1767
1694
|
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.");
|
|
1768
1695
|
if (getComputedStyle(container).position === "static") container.style.position = "relative";
|
|
1696
|
+
container.style.overflow = "hidden";
|
|
1769
1697
|
container.style.userSelect = "none";
|
|
1770
1698
|
container.style.webkitUserSelect = "none";
|
|
1771
1699
|
const translate_options = () => {
|
|
@@ -1777,12 +1705,13 @@ var DomSurface = class DomSurface {
|
|
|
1777
1705
|
snap_threshold_px: style.snap_threshold_px / zoom
|
|
1778
1706
|
};
|
|
1779
1707
|
};
|
|
1780
|
-
this.translate_orchestrator = new
|
|
1708
|
+
this.translate_orchestrator = new require_model.TranslateOrchestrator({
|
|
1781
1709
|
get_doc: () => this.editor_internal().doc,
|
|
1782
1710
|
emit: () => this.editor_internal().emit(),
|
|
1783
1711
|
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1784
1712
|
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1785
|
-
options: translate_options
|
|
1713
|
+
options: translate_options,
|
|
1714
|
+
project_delta: (id, d) => this._geometry_provider?.world_delta_to_local(id, d) ?? d
|
|
1786
1715
|
});
|
|
1787
1716
|
const resize_options = () => {
|
|
1788
1717
|
const style = this.editor.style;
|
|
@@ -1793,7 +1722,7 @@ var DomSurface = class DomSurface {
|
|
|
1793
1722
|
snap_threshold_px: style.snap_threshold_px / zoom
|
|
1794
1723
|
};
|
|
1795
1724
|
};
|
|
1796
|
-
this.resize_orchestrator = new ResizeOrchestrator({
|
|
1725
|
+
this.resize_orchestrator = new require_model.ResizeOrchestrator({
|
|
1797
1726
|
get_doc: () => this.editor_internal().doc,
|
|
1798
1727
|
emit: () => this.editor_internal().emit(),
|
|
1799
1728
|
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
@@ -1807,7 +1736,7 @@ var DomSurface = class DomSurface {
|
|
|
1807
1736
|
}
|
|
1808
1737
|
});
|
|
1809
1738
|
const rotate_options = () => ({ angle_snap_step_radians: this.editor.style.angle_snap_step_radians });
|
|
1810
|
-
this.rotate_orchestrator = new
|
|
1739
|
+
this.rotate_orchestrator = new require_model.RotateOrchestrator({
|
|
1811
1740
|
get_doc: () => this.editor_internal().doc,
|
|
1812
1741
|
emit: () => this.editor_internal().emit(),
|
|
1813
1742
|
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
@@ -1829,7 +1758,7 @@ var DomSurface = class DomSurface {
|
|
|
1829
1758
|
},
|
|
1830
1759
|
subscribe_translate_commit: (cb) => this.editor_internal().subscribe_translate_commit(cb)
|
|
1831
1760
|
};
|
|
1832
|
-
this.nudge_dwell_watcher = new
|
|
1761
|
+
this.nudge_dwell_watcher = new require_model.NudgeDwellWatcher({
|
|
1833
1762
|
editor: editor_for_watcher,
|
|
1834
1763
|
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1835
1764
|
options: translate_options,
|
|
@@ -1848,6 +1777,7 @@ var DomSurface = class DomSurface {
|
|
|
1848
1777
|
this.hud = new _grida_hud.Surface(this.hud_canvas, {
|
|
1849
1778
|
pick: (p) => this.hit_test(p[0], p[1]),
|
|
1850
1779
|
shapeOf: (id) => this.shape_of(id),
|
|
1780
|
+
vectorOf: (id) => this.vector_of(id),
|
|
1851
1781
|
onIntent: (i) => this.commit_intent(i),
|
|
1852
1782
|
style: {
|
|
1853
1783
|
chromeColor: editor.style.chrome_color,
|
|
@@ -1900,11 +1830,14 @@ var DomSurface = class DomSurface {
|
|
|
1900
1830
|
hud_canvas: this.hud_canvas,
|
|
1901
1831
|
camera: this.camera,
|
|
1902
1832
|
editor,
|
|
1903
|
-
handle: { detach: () => {} }
|
|
1833
|
+
handle: { detach: () => {} },
|
|
1834
|
+
is_attended: () => this.attention.is_attended()
|
|
1904
1835
|
});
|
|
1905
1836
|
if (options.gestures !== false) applyDefaultGestures(this.gestures);
|
|
1906
1837
|
const unsub = editor.subscribe(() => {
|
|
1907
1838
|
this.current_tool = editor.state.tool;
|
|
1839
|
+
this.hud.setVectorSelectionMode(this.current_tool.type === "lasso" ? "lasso" : "marquee");
|
|
1840
|
+
this.hud.setVectorBendMode(this.current_tool.type === "bend" ? "always" : "auto");
|
|
1908
1841
|
this.render();
|
|
1909
1842
|
this.sync_surface_selection();
|
|
1910
1843
|
this.hud.setPixelGrid({
|
|
@@ -1921,6 +1854,23 @@ var DomSurface = class DomSurface {
|
|
|
1921
1854
|
this.pending_insert = null;
|
|
1922
1855
|
}
|
|
1923
1856
|
}
|
|
1857
|
+
if (this.vector_edit && !this.active_preview) {
|
|
1858
|
+
const ses = this.vector_edit;
|
|
1859
|
+
const live_source = this.editor_internal().doc.is_vector_edit_target(ses.node_id);
|
|
1860
|
+
const had_selection = ses.selected_vertices.length > 0 || ses.selected_segments.length > 0 || ses.selected_tangents.length > 0;
|
|
1861
|
+
if (live_source === null || live_source.kind !== ses.source.kind) {
|
|
1862
|
+
if (had_selection) {
|
|
1863
|
+
ses.clear_selection();
|
|
1864
|
+
this.sync_selection_mirror();
|
|
1865
|
+
}
|
|
1866
|
+
} else {
|
|
1867
|
+
const live_d = source_to_session_d(live_source);
|
|
1868
|
+
if (live_d !== ses.last_seen_d) {
|
|
1869
|
+
ses.reconcile_after_external_mutation(live_d);
|
|
1870
|
+
if (had_selection) this.sync_selection_mirror();
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1924
1874
|
this.request_redraw();
|
|
1925
1875
|
});
|
|
1926
1876
|
this.teardown.push(unsub);
|
|
@@ -1954,7 +1904,7 @@ var DomSurface = class DomSurface {
|
|
|
1954
1904
|
if (computed === "") return null;
|
|
1955
1905
|
return {
|
|
1956
1906
|
computed,
|
|
1957
|
-
resolved_paint:
|
|
1907
|
+
resolved_paint: require_model.paint.parse(computed)
|
|
1958
1908
|
};
|
|
1959
1909
|
}
|
|
1960
1910
|
});
|
|
@@ -2006,8 +1956,8 @@ var DomSurface = class DomSurface {
|
|
|
2006
1956
|
});
|
|
2007
1957
|
this.teardown.push(() => internal.set_surface_hover_override_driver(null));
|
|
2008
1958
|
}
|
|
2009
|
-
paint(_snapshot) {}
|
|
2010
1959
|
hit_test(x, y) {
|
|
1960
|
+
if (this.vector_edit) return null;
|
|
2011
1961
|
return this.pick_at(x, y, false);
|
|
2012
1962
|
}
|
|
2013
1963
|
/** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
|
|
@@ -2035,7 +1985,7 @@ var DomSurface = class DomSurface {
|
|
|
2035
1985
|
* `<text>` / `<use>` / transformed nodes). Tolerance is screen-
|
|
2036
1986
|
* CSS-px, converted to world units via `camera.zoom` so the band
|
|
2037
1987
|
* stays the same width on screen regardless of zoom. Has known
|
|
2038
|
-
* issues — see
|
|
1988
|
+
* issues — see https://grida.co/docs/wg/feat-svg-editor/hit-test.
|
|
2039
1989
|
*
|
|
2040
1990
|
* - **`<= 0` (legacy elementFromPoint)** — opt-out of the cmath
|
|
2041
1991
|
* picker. Uses the browser's painted-pixel hit-test plus a
|
|
@@ -2113,15 +2063,17 @@ var DomSurface = class DomSurface {
|
|
|
2113
2063
|
if (this._z_order_cache.length > 0 && this._z_order_cache[0] === root_id) return this._z_order_cache.slice(1);
|
|
2114
2064
|
return this._z_order_cache.filter((id) => id !== root_id);
|
|
2115
2065
|
}
|
|
2116
|
-
on_input(_listener) {
|
|
2117
|
-
return () => {};
|
|
2118
|
-
}
|
|
2119
2066
|
dispose() {
|
|
2120
2067
|
if (this.text_edit) {
|
|
2121
2068
|
this.text_edit.cancel();
|
|
2122
2069
|
this.text_edit = null;
|
|
2123
2070
|
this.text_edit_target = null;
|
|
2124
2071
|
}
|
|
2072
|
+
if (this.vector_edit) {
|
|
2073
|
+
this.vector_edit = null;
|
|
2074
|
+
this.vector_edit_region_baseline = null;
|
|
2075
|
+
this.hud.setVectorSelection(null);
|
|
2076
|
+
}
|
|
2125
2077
|
this.gestures._dispose();
|
|
2126
2078
|
this.translate_orchestrator.cancel();
|
|
2127
2079
|
this.resize_orchestrator.cancel();
|
|
@@ -2189,6 +2141,17 @@ var DomSurface = class DomSurface {
|
|
|
2189
2141
|
style.left = "0";
|
|
2190
2142
|
style.top = "0";
|
|
2191
2143
|
style.transformOrigin = "0 0";
|
|
2144
|
+
const vb = this.svg_root.getAttribute("viewBox");
|
|
2145
|
+
if (vb) {
|
|
2146
|
+
const parts = vb.split(/[\s,]+/).map(Number);
|
|
2147
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) {
|
|
2148
|
+
const [, , w, h] = parts;
|
|
2149
|
+
if (w > 0 && h > 0) {
|
|
2150
|
+
style.width = `${w}px`;
|
|
2151
|
+
style.height = `${h}px`;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2192
2155
|
}
|
|
2193
2156
|
/**
|
|
2194
2157
|
* Push the current camera transform to the SVG as a CSS `matrix(...)`.
|
|
@@ -2488,14 +2451,15 @@ var DomSurface = class DomSurface {
|
|
|
2488
2451
|
const neighbor_ids = compute_neighborhood(doc, ids);
|
|
2489
2452
|
const agent_id_set = /* @__PURE__ */ new Set();
|
|
2490
2453
|
for (const id of ids) for (const inner of snap_descent(doc, id)) agent_id_set.add(inner);
|
|
2454
|
+
const bounds_of = (id) => this._geometry_provider?.bounds_of(id) ?? null;
|
|
2491
2455
|
const agents = [];
|
|
2492
2456
|
for (const id of agent_id_set) {
|
|
2493
|
-
const r =
|
|
2457
|
+
const r = bounds_of(id);
|
|
2494
2458
|
if (r) agents.push(r);
|
|
2495
2459
|
}
|
|
2496
2460
|
const neighbors = [];
|
|
2497
2461
|
for (const id of neighbor_ids) {
|
|
2498
|
-
const r =
|
|
2462
|
+
const r = bounds_of(id);
|
|
2499
2463
|
if (r) neighbors.push(r);
|
|
2500
2464
|
}
|
|
2501
2465
|
return new SnapSession({
|
|
@@ -2836,6 +2800,17 @@ var DomSurface = class DomSurface {
|
|
|
2836
2800
|
}
|
|
2837
2801
|
}
|
|
2838
2802
|
}
|
|
2803
|
+
if (tool.type === "insert-text") {
|
|
2804
|
+
if (kind === "pointer_down" && e.button === 0) {
|
|
2805
|
+
const world = this.camera.screen_to_world({
|
|
2806
|
+
x,
|
|
2807
|
+
y
|
|
2808
|
+
});
|
|
2809
|
+
this.editor.set_tool({ type: "cursor" });
|
|
2810
|
+
this.begin_text_insert(world);
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2839
2814
|
const button = e.button === 0 ? "primary" : e.button === 2 ? "secondary" : "middle";
|
|
2840
2815
|
let event;
|
|
2841
2816
|
if (kind === "pointer_move") event = {
|
|
@@ -2894,7 +2869,7 @@ var DomSurface = class DomSurface {
|
|
|
2894
2869
|
}
|
|
2895
2870
|
/** Transition `armed` → `drawing`: open `insert_preview` + snap session. */
|
|
2896
2871
|
arm_to_draw(armed) {
|
|
2897
|
-
const session = this.editor.commands.insert_preview(armed.tag,
|
|
2872
|
+
const session = this.editor.commands.insert_preview(armed.tag, require_model.insertions.initial_attrs(armed.tag, armed.anchor));
|
|
2898
2873
|
const snap_session = this.editor.style.snap_enabled && (armed.tag === "rect" || armed.tag === "ellipse") ? this.open_snap_session_for([session.id]) : null;
|
|
2899
2874
|
this.pending_insert = {
|
|
2900
2875
|
phase: "drawing",
|
|
@@ -2916,7 +2891,7 @@ var DomSurface = class DomSurface {
|
|
|
2916
2891
|
shift: mods.shift,
|
|
2917
2892
|
alt: mods.alt
|
|
2918
2893
|
};
|
|
2919
|
-
drawing.session.update(
|
|
2894
|
+
drawing.session.update(require_model.insertions.compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
|
|
2920
2895
|
}
|
|
2921
2896
|
/** Commit on pointer-up. `armed` → one-shot `commands.insert` with
|
|
2922
2897
|
* `default_attrs` (click-no-drag, never touches the IR mid-gesture).
|
|
@@ -2924,7 +2899,7 @@ var DomSurface = class DomSurface {
|
|
|
2924
2899
|
commit_insert_gesture(screen_pt, mods) {
|
|
2925
2900
|
const cur = this.pending_insert;
|
|
2926
2901
|
if (!cur) return;
|
|
2927
|
-
if (cur.phase === "armed") this.editor.commands.insert(cur.tag,
|
|
2902
|
+
if (cur.phase === "armed") this.editor.commands.insert(cur.tag, require_model.insertions.default_attrs(cur.tag, cur.anchor));
|
|
2928
2903
|
else {
|
|
2929
2904
|
this.push_drawing_frame(cur, screen_pt, mods);
|
|
2930
2905
|
cur.session.commit();
|
|
@@ -2975,11 +2950,20 @@ var DomSurface = class DomSurface {
|
|
|
2975
2950
|
this.container.style.cursor = "crosshair";
|
|
2976
2951
|
return;
|
|
2977
2952
|
}
|
|
2953
|
+
if (this.current_tool.type === "insert-text") {
|
|
2954
|
+
this.container.style.cursor = "text";
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2978
2957
|
this.container.style.cursor = this.hud.cursorCss();
|
|
2979
2958
|
}
|
|
2980
2959
|
on_keydown(e) {
|
|
2981
2960
|
if (this.text_edit) return;
|
|
2982
|
-
if (e.code
|
|
2961
|
+
if (e.code !== "Escape" && !this.attention.is_attended()) return;
|
|
2962
|
+
if (e.code === "Escape") {
|
|
2963
|
+
const canceled = this.cancel_in_flight();
|
|
2964
|
+
if (!this.attention.is_attended()) return;
|
|
2965
|
+
if (!canceled && this.vector_edit) this.exit_vector_edit();
|
|
2966
|
+
}
|
|
2983
2967
|
if ((this.active_preview || this.translate_orchestrator.has_active_session() || this.resize_orchestrator.has_active_session() || this.rotate_orchestrator.has_active_session() || this.pending_insert) && e.code !== "Escape") return;
|
|
2984
2968
|
if (this.editor.keymap.claims(e)) e.preventDefault();
|
|
2985
2969
|
this.editor.keymap.dispatch(e);
|
|
@@ -2992,6 +2976,15 @@ var DomSurface = class DomSurface {
|
|
|
2992
2976
|
case "deselect_all":
|
|
2993
2977
|
this.editor.commands.deselect();
|
|
2994
2978
|
return;
|
|
2979
|
+
case "clear_vector_selection": {
|
|
2980
|
+
if (!this.vector_edit) return;
|
|
2981
|
+
const before = this.vector_edit.snapshot_selection();
|
|
2982
|
+
this.vector_edit.clear_selection();
|
|
2983
|
+
this.sync_selection_mirror();
|
|
2984
|
+
this.redraw();
|
|
2985
|
+
this.record_vector_selection_change(before, "clear vector selection");
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2995
2988
|
case "translate":
|
|
2996
2989
|
this.handle_translate(intent);
|
|
2997
2990
|
return;
|
|
@@ -3004,6 +2997,9 @@ var DomSurface = class DomSurface {
|
|
|
3004
2997
|
case "marquee_select":
|
|
3005
2998
|
this.handle_marquee(intent);
|
|
3006
2999
|
return;
|
|
3000
|
+
case "lasso_select":
|
|
3001
|
+
this.handle_lasso_select(intent);
|
|
3002
|
+
return;
|
|
3007
3003
|
case "set_endpoint":
|
|
3008
3004
|
this.handle_set_endpoint(intent);
|
|
3009
3005
|
return;
|
|
@@ -3011,6 +3007,33 @@ var DomSurface = class DomSurface {
|
|
|
3011
3007
|
this.editor.commands.select(intent.id);
|
|
3012
3008
|
this.editor.enter_content_edit(intent.id);
|
|
3013
3009
|
return;
|
|
3010
|
+
case "exit_content_edit":
|
|
3011
|
+
this.exit_vector_edit();
|
|
3012
|
+
return;
|
|
3013
|
+
case "select_vertex":
|
|
3014
|
+
this.handle_select_vertex(intent);
|
|
3015
|
+
return;
|
|
3016
|
+
case "translate_vertices":
|
|
3017
|
+
this.handle_translate_vertices(intent);
|
|
3018
|
+
return;
|
|
3019
|
+
case "translate_vector_selection":
|
|
3020
|
+
this.handle_translate_vector_selection(intent);
|
|
3021
|
+
return;
|
|
3022
|
+
case "select_segment":
|
|
3023
|
+
this.handle_select_segment(intent);
|
|
3024
|
+
return;
|
|
3025
|
+
case "select_tangent":
|
|
3026
|
+
this.handle_select_tangent(intent);
|
|
3027
|
+
return;
|
|
3028
|
+
case "set_tangent":
|
|
3029
|
+
this.handle_set_tangent_intent(intent);
|
|
3030
|
+
return;
|
|
3031
|
+
case "split_segment":
|
|
3032
|
+
this.handle_split_segment(intent);
|
|
3033
|
+
return;
|
|
3034
|
+
case "bend_segment":
|
|
3035
|
+
this.handle_bend_segment(intent);
|
|
3036
|
+
return;
|
|
3014
3037
|
case "cancel_gesture":
|
|
3015
3038
|
this.cancel_in_flight();
|
|
3016
3039
|
return;
|
|
@@ -3050,7 +3073,7 @@ var DomSurface = class DomSurface {
|
|
|
3050
3073
|
}
|
|
3051
3074
|
handle_resize(intent) {
|
|
3052
3075
|
if (intent.ids.length === 0) return;
|
|
3053
|
-
for (const id of intent.ids) if (!
|
|
3076
|
+
for (const id of intent.ids) if (!require_model.resize_pipeline.intent.is_resizable_node(this.editor.document, id)) return;
|
|
3054
3077
|
const dir = intent.anchor;
|
|
3055
3078
|
let target_width;
|
|
3056
3079
|
let target_height;
|
|
@@ -3098,9 +3121,7 @@ var DomSurface = class DomSurface {
|
|
|
3098
3121
|
for (const v of verdicts.values()) {
|
|
3099
3122
|
if (v.kind !== "refuse") continue;
|
|
3100
3123
|
const message = v.reason === "non-trivial-transform" ? "Cannot rotate cleanly — element has a composite transform. Use Flatten Transform first." : v.reason === "text-with-glyph-rotate" ? "Cannot rotate — text has per-glyph rotation. Edit `rotate=` or remove it first." : v.reason === "css-property-transform" ? "Cannot rotate — transform is set via CSS. Move the declaration to the `transform` attribute first." : "Cannot rotate — element has an animated transform. Remove `<animateTransform>` first.";
|
|
3101
|
-
|
|
3102
|
-
if (typeof hud.setTransientToast === "function") hud.setTransientToast(message);
|
|
3103
|
-
else console.warn(`[svg-editor] ${message}`);
|
|
3124
|
+
console.warn(`[svg-editor] ${message}`);
|
|
3104
3125
|
return;
|
|
3105
3126
|
}
|
|
3106
3127
|
}
|
|
@@ -3185,7 +3206,28 @@ var DomSurface = class DomSurface {
|
|
|
3185
3206
|
y: t.y
|
|
3186
3207
|
};
|
|
3187
3208
|
}
|
|
3209
|
+
/**
|
|
3210
|
+
* Capture the vector-edit sub-selection on the first region-gesture
|
|
3211
|
+
* preview, and reuse it for every subsequent preview + the commit.
|
|
3212
|
+
* Anchors additive merging and tangent-candidate eligibility to the
|
|
3213
|
+
* gesture-start state so they don't shift mid-drag. Shared by marquee
|
|
3214
|
+
* and lasso — both gestures emit a preview-per-move, both consume the
|
|
3215
|
+
* same baseline. See `vector_edit_region_baseline` doc-comment for the
|
|
3216
|
+
* full rationale. Caller must have a non-null `this.vector_edit`.
|
|
3217
|
+
*/
|
|
3218
|
+
ensure_region_baseline() {
|
|
3219
|
+
if (!this.vector_edit_region_baseline) this.vector_edit_region_baseline = {
|
|
3220
|
+
vertices: this.vector_edit.selected_vertices.slice(),
|
|
3221
|
+
segments: this.vector_edit.selected_segments.slice(),
|
|
3222
|
+
tangents: this.vector_edit.selected_tangents.map((t) => [t[0], t[1]])
|
|
3223
|
+
};
|
|
3224
|
+
return this.vector_edit_region_baseline;
|
|
3225
|
+
}
|
|
3188
3226
|
handle_marquee(intent) {
|
|
3227
|
+
if (this.vector_edit) {
|
|
3228
|
+
this.handle_marquee_vectors(intent);
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
3189
3231
|
if (intent.phase !== "commit") return;
|
|
3190
3232
|
const ids = [];
|
|
3191
3233
|
for (const id of this.element_index.keys()) {
|
|
@@ -3200,7 +3242,170 @@ var DomSurface = class DomSurface {
|
|
|
3200
3242
|
}
|
|
3201
3243
|
this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
|
|
3202
3244
|
}
|
|
3203
|
-
|
|
3245
|
+
/**
|
|
3246
|
+
* Vector marquee predicate — applies the **vertex-priority precedence
|
|
3247
|
+
* rule** ported from the main editor:
|
|
3248
|
+
*
|
|
3249
|
+
* 1. Vertices: keep those whose container-space position falls inside
|
|
3250
|
+
* the marquee rect.
|
|
3251
|
+
* 2. Tangents: keep only those at the (new) selection's neighbouring
|
|
3252
|
+
* vertices whose control point falls inside the rect.
|
|
3253
|
+
* 3. Segments: keep only segments **fully contained** in the rect AND
|
|
3254
|
+
* whose endpoints are NOT among the selected vertices. (If a vertex
|
|
3255
|
+
* is selected, the segments meeting it would double-credit the
|
|
3256
|
+
* drag, so they're dropped — "vertex priority.")
|
|
3257
|
+
*
|
|
3258
|
+
* The rect comes in container CSS-px (HUD's frame). We project it back
|
|
3259
|
+
* to path-local by applying the inverse-CTM linear part to its size and
|
|
3260
|
+
* shifting its position via `project_delta_inverse_ctm` against the
|
|
3261
|
+
* top-left in doc-space — but a cleaner approach is to project each
|
|
3262
|
+
* vertex/tangent/segment-sample into doc-space and test against the rect
|
|
3263
|
+
* directly. For consistency with how `vector_of` projects, we do the
|
|
3264
|
+
* "project geometry to doc-space, test against doc-space rect" approach.
|
|
3265
|
+
*/
|
|
3266
|
+
handle_marquee_vectors(intent) {
|
|
3267
|
+
if (!this.vector_edit) return;
|
|
3268
|
+
const node_id = this.vector_edit.node_id;
|
|
3269
|
+
const el = this.element_index.get(node_id);
|
|
3270
|
+
if (!(el instanceof SVGGraphicsElement)) return;
|
|
3271
|
+
if (typeof el.getScreenCTM !== "function") return;
|
|
3272
|
+
const ctm = el.getScreenCTM();
|
|
3273
|
+
if (!ctm) return;
|
|
3274
|
+
const cr = this.container.getBoundingClientRect();
|
|
3275
|
+
const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
|
|
3276
|
+
const model = this.session_model();
|
|
3277
|
+
if (!model) return;
|
|
3278
|
+
const rect = intent.rect;
|
|
3279
|
+
const baseline = this.ensure_region_baseline();
|
|
3280
|
+
const pre_selection = {
|
|
3281
|
+
vertices: baseline.vertices,
|
|
3282
|
+
segments: baseline.segments,
|
|
3283
|
+
tangents: baseline.tangents
|
|
3284
|
+
};
|
|
3285
|
+
const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
|
|
3286
|
+
const vertex_hits = marquee.points_in_rect(candidates.vertices, rect);
|
|
3287
|
+
const tangent_hits = marquee.points_in_rect(candidates.tangents, rect);
|
|
3288
|
+
const rect_local = inverse_project_rect(rect, ctm, offset);
|
|
3289
|
+
const vertex_hit_set = new Set(vertex_hits);
|
|
3290
|
+
const segment_hits = [];
|
|
3291
|
+
if (rect_local) {
|
|
3292
|
+
const segs = model.snapshot().segments;
|
|
3293
|
+
for (const sid of candidates.segments) {
|
|
3294
|
+
const s = segs[sid];
|
|
3295
|
+
if (vertex_hit_set.has(s.a) || vertex_hit_set.has(s.b)) continue;
|
|
3296
|
+
if (model.segmentContainedByRect(sid, rect_local)) segment_hits.push(sid);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
const merged = marquee.merge_subpath_hits(pre_selection, {
|
|
3300
|
+
vertices: vertex_hits,
|
|
3301
|
+
segments: segment_hits,
|
|
3302
|
+
tangents: tangent_hits
|
|
3303
|
+
}, intent.additive);
|
|
3304
|
+
this.vector_edit.set_selection(merged);
|
|
3305
|
+
this.sync_selection_mirror();
|
|
3306
|
+
if (intent.phase === "commit") {
|
|
3307
|
+
const baseline_snapshot = Object.freeze({
|
|
3308
|
+
vertices: baseline.vertices,
|
|
3309
|
+
segments: baseline.segments,
|
|
3310
|
+
tangents: baseline.tangents
|
|
3311
|
+
});
|
|
3312
|
+
this.vector_edit_region_baseline = null;
|
|
3313
|
+
this.record_vector_selection_change(baseline_snapshot, "marquee select");
|
|
3314
|
+
}
|
|
3315
|
+
this.redraw();
|
|
3316
|
+
}
|
|
3317
|
+
/**
|
|
3318
|
+
* Lasso (freeform polygon) sub-selection — vector analogue of
|
|
3319
|
+
* `handle_marquee`. Per the main editor's decision
|
|
3320
|
+
* (editor/grida-canvas/reducers/methods/vector.ts:163–291 +
|
|
3321
|
+
* event-target.reducer.ts:629–641) lasso targets **vertices and
|
|
3322
|
+
* tangents only** — segments are NOT tested against the polygon. The
|
|
3323
|
+
* segment-vs-region test is rect-only and lives in the marquee path.
|
|
3324
|
+
*
|
|
3325
|
+
* Lifecycle / baseline behaviour matches marquee: snapshot on first
|
|
3326
|
+
* preview, reuse for additive merge and tangent-eligibility, clear on
|
|
3327
|
+
* commit and on vector-edit exit. Scene (non-vector-edit) lasso is a
|
|
3328
|
+
* follow-up.
|
|
3329
|
+
*/
|
|
3330
|
+
handle_lasso_select(intent) {
|
|
3331
|
+
if (!this.vector_edit) return;
|
|
3332
|
+
const node_id = this.vector_edit.node_id;
|
|
3333
|
+
const el = this.element_index.get(node_id);
|
|
3334
|
+
if (!(el instanceof SVGGraphicsElement)) return;
|
|
3335
|
+
if (typeof el.getScreenCTM !== "function") return;
|
|
3336
|
+
const ctm = el.getScreenCTM();
|
|
3337
|
+
if (!ctm) return;
|
|
3338
|
+
const cr = this.container.getBoundingClientRect();
|
|
3339
|
+
const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
|
|
3340
|
+
const model = this.session_model();
|
|
3341
|
+
if (!model) return;
|
|
3342
|
+
const polygon = intent.polygon;
|
|
3343
|
+
if (polygon.length < 3) return;
|
|
3344
|
+
const baseline = this.ensure_region_baseline();
|
|
3345
|
+
const pre_selection = {
|
|
3346
|
+
vertices: baseline.vertices,
|
|
3347
|
+
segments: baseline.segments,
|
|
3348
|
+
tangents: baseline.tangents
|
|
3349
|
+
};
|
|
3350
|
+
const candidates = marquee.subpath_select_candidates(model, pre_selection, (p) => project_point_through_ctm(p[0], p[1], ctm, offset));
|
|
3351
|
+
const vertex_hits = marquee.points_in_polygon(candidates.vertices, polygon);
|
|
3352
|
+
const tangent_hits = marquee.points_in_polygon(candidates.tangents, polygon);
|
|
3353
|
+
const merged = marquee.merge_subpath_hits(pre_selection, {
|
|
3354
|
+
vertices: vertex_hits,
|
|
3355
|
+
segments: [],
|
|
3356
|
+
tangents: tangent_hits
|
|
3357
|
+
}, intent.additive);
|
|
3358
|
+
this.vector_edit.set_selection(merged);
|
|
3359
|
+
this.sync_selection_mirror();
|
|
3360
|
+
if (intent.phase === "commit") {
|
|
3361
|
+
const baseline_snapshot = Object.freeze({
|
|
3362
|
+
vertices: baseline.vertices,
|
|
3363
|
+
segments: baseline.segments,
|
|
3364
|
+
tangents: baseline.tangents
|
|
3365
|
+
});
|
|
3366
|
+
this.vector_edit_region_baseline = null;
|
|
3367
|
+
this.record_vector_selection_change(baseline_snapshot, "lasso select");
|
|
3368
|
+
}
|
|
3369
|
+
this.redraw();
|
|
3370
|
+
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Dispatched by the editor when `enter_content_edit(id)` is called. The
|
|
3373
|
+
* editor has already gated on text-OR-path eligibility; this method
|
|
3374
|
+
* routes on the actual tag.
|
|
3375
|
+
*/
|
|
3376
|
+
enter_content_edit(id) {
|
|
3377
|
+
if (this.text_edit || this.vector_edit) return false;
|
|
3378
|
+
const tag = this.tag_of(id);
|
|
3379
|
+
if (tag === "text" || tag === "tspan") return this.enter_text_edit(id);
|
|
3380
|
+
if (this.editor_internal().doc.is_vector_edit_target(id) !== null) return this.enter_vector_edit(id);
|
|
3381
|
+
return false;
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Place a new single-line `<text>` at `world` and immediately enter
|
|
3385
|
+
* content-edit on it. Creation + first edit are bracketed in one history
|
|
3386
|
+
* preview (via `insert_text_preview`): committing with content is one
|
|
3387
|
+
* undo step, exiting empty discards the node entirely (empty-equals-
|
|
3388
|
+
* delete). The bracket is finalized in {@link enter_text_edit}'s
|
|
3389
|
+
* commit/cancel callbacks via `this.pending_text_insert`.
|
|
3390
|
+
*
|
|
3391
|
+
* Default font appearance lives in `core/insertions.ts`
|
|
3392
|
+
* (`default_text_attrs`), alongside the shape insertion defaults — not
|
|
3393
|
+
* hard-coded here — so the per-element insert semantics stay in core (P3).
|
|
3394
|
+
*/
|
|
3395
|
+
begin_text_insert(world) {
|
|
3396
|
+
if (this.text_edit || this.vector_edit) return;
|
|
3397
|
+
const session = this.editor_internal().insert_text_preview(require_model.insertions.default_text_attrs(world));
|
|
3398
|
+
this.pending_text_insert = {
|
|
3399
|
+
id: session.id,
|
|
3400
|
+
session
|
|
3401
|
+
};
|
|
3402
|
+
this.editor.enter_content_edit(session.id);
|
|
3403
|
+
if (this.text_edit_target !== session.id) {
|
|
3404
|
+
this.pending_text_insert = null;
|
|
3405
|
+
session.discard();
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
enter_text_edit(id) {
|
|
3204
3409
|
if (this.text_edit) return false;
|
|
3205
3410
|
const el = this.element_index.get(id);
|
|
3206
3411
|
if (!(el instanceof SVGElement)) return false;
|
|
@@ -3216,15 +3421,6 @@ var DomSurface = class DomSurface {
|
|
|
3216
3421
|
const text_surface = new SvgTextSurface(this.element_index.get(id) ?? el);
|
|
3217
3422
|
const is_mac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
|
3218
3423
|
let settled = false;
|
|
3219
|
-
const cleanup_after_commit_or_cancel = () => {
|
|
3220
|
-
this.editor.commands.set_mode("select");
|
|
3221
|
-
this.render();
|
|
3222
|
-
this.sync_surface_selection();
|
|
3223
|
-
this.sync_cursor();
|
|
3224
|
-
this.redraw();
|
|
3225
|
-
this.text_edit = null;
|
|
3226
|
-
this.text_edit_target = null;
|
|
3227
|
-
};
|
|
3228
3424
|
this.text_edit = (0, _grida_text_editor_dom.createTextEditor)({
|
|
3229
3425
|
container: this.container,
|
|
3230
3426
|
initialText: this.text_edit_original,
|
|
@@ -3240,16 +3436,12 @@ var DomSurface = class DomSurface {
|
|
|
3240
3436
|
onCommit: (final_text) => {
|
|
3241
3437
|
if (settled) return;
|
|
3242
3438
|
settled = true;
|
|
3243
|
-
|
|
3244
|
-
cleanup_after_commit_or_cancel();
|
|
3245
|
-
if (final_text !== this.text_edit_original) this.editor.commands.set_text(final_text);
|
|
3439
|
+
this.finalize_text_exit(id, final_text);
|
|
3246
3440
|
},
|
|
3247
3441
|
onCancel: () => {
|
|
3248
3442
|
if (settled) return;
|
|
3249
3443
|
settled = true;
|
|
3250
|
-
|
|
3251
|
-
cleanup_after_commit_or_cancel();
|
|
3252
|
-
doc.emit();
|
|
3444
|
+
this.finalize_text_exit(id, this.text_edit_original);
|
|
3253
3445
|
},
|
|
3254
3446
|
onUndoFallthrough: () => {
|
|
3255
3447
|
this.text_edit?.commit();
|
|
@@ -3263,6 +3455,726 @@ var DomSurface = class DomSurface {
|
|
|
3263
3455
|
});
|
|
3264
3456
|
return true;
|
|
3265
3457
|
}
|
|
3458
|
+
/**
|
|
3459
|
+
* Exit inline text-edit back to select mode. P4 — observers should see a
|
|
3460
|
+
* consistent post-edit state, so do all observable mutations + surface
|
|
3461
|
+
* syncs first, then clear the text-edit handles last (so anything polling
|
|
3462
|
+
* "is text-edit active?" still says yes until the world is settled).
|
|
3463
|
+
*/
|
|
3464
|
+
cleanup_text_edit() {
|
|
3465
|
+
this.editor.commands.set_mode("select");
|
|
3466
|
+
this.render();
|
|
3467
|
+
this.sync_surface_selection();
|
|
3468
|
+
this.sync_cursor();
|
|
3469
|
+
this.redraw();
|
|
3470
|
+
this.text_edit = null;
|
|
3471
|
+
this.text_edit_target = null;
|
|
3472
|
+
}
|
|
3473
|
+
/**
|
|
3474
|
+
* Realize the result of a text content-edit session and exit. `result`
|
|
3475
|
+
* is the text that should remain — the typed text on commit, the original
|
|
3476
|
+
* on cancel. Implements the empty-equals-delete rule (design:
|
|
3477
|
+
* docs/wg/feat-svg-editor/text-tool.md): an empty result removes the node.
|
|
3478
|
+
* (see test/svg-editor-text-empty-delete.md)
|
|
3479
|
+
*
|
|
3480
|
+
* The empty-equals-delete decision is pure and lives in
|
|
3481
|
+
* {@link resolve_text_exit} (`core/text-edit.ts`) — tested headlessly.
|
|
3482
|
+
* This method is the thin dispatcher that realizes each action against
|
|
3483
|
+
* the surface + editor.
|
|
3484
|
+
*/
|
|
3485
|
+
finalize_text_exit(id, result) {
|
|
3486
|
+
const internal = this.editor_internal();
|
|
3487
|
+
const insert = this.pending_text_insert;
|
|
3488
|
+
this.pending_text_insert = null;
|
|
3489
|
+
const action = resolve_text_exit({
|
|
3490
|
+
origin: insert && insert.id === id ? "fresh" : "existing",
|
|
3491
|
+
result,
|
|
3492
|
+
original: this.text_edit_original
|
|
3493
|
+
});
|
|
3494
|
+
if (action.kind === "commit_insert" || action.kind === "discard_insert") {
|
|
3495
|
+
if (!insert) return;
|
|
3496
|
+
if (action.kind === "discard_insert") {
|
|
3497
|
+
this.cleanup_text_edit();
|
|
3498
|
+
insert.session.discard();
|
|
3499
|
+
} else {
|
|
3500
|
+
this.cleanup_text_edit();
|
|
3501
|
+
insert.session.commit();
|
|
3502
|
+
}
|
|
3503
|
+
return;
|
|
3504
|
+
}
|
|
3505
|
+
internal.doc.set_text(id, this.text_edit_original);
|
|
3506
|
+
this.cleanup_text_edit();
|
|
3507
|
+
switch (action.kind) {
|
|
3508
|
+
case "remove":
|
|
3509
|
+
this.editor.commands.select(id);
|
|
3510
|
+
this.editor.commands.remove();
|
|
3511
|
+
break;
|
|
3512
|
+
case "set_text":
|
|
3513
|
+
this.editor.commands.set_text(action.value);
|
|
3514
|
+
break;
|
|
3515
|
+
case "noop":
|
|
3516
|
+
internal.emit();
|
|
3517
|
+
break;
|
|
3518
|
+
default:
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
/**
|
|
3522
|
+
* Enter path-vertex-edit mode. Mirrors `enter_text_edit` shape: capture
|
|
3523
|
+
* the original `d`, flip the editor mode, push a vector-selection mirror
|
|
3524
|
+
* to the HUD, and start serving `vectorOf` from the live session.
|
|
3525
|
+
*
|
|
3526
|
+
* Exit happens via `exit_vector_edit` (Esc / `set_mode("select")` /
|
|
3527
|
+
* dblclick away). On exit, any in-flight preview is discarded; committed
|
|
3528
|
+
* intermediate states stay (each gesture's commit was its own history
|
|
3529
|
+
* entry, per the gesture-bracketed history doctrine).
|
|
3530
|
+
*
|
|
3531
|
+
* The enter is itself a history step (tagged `vector-mode-enter`) so
|
|
3532
|
+
* undo from inside vector-edit reverts the entry — symmetric to the
|
|
3533
|
+
* `vector-mode-exit` push in {@link exit_vector_edit}. Both deltas
|
|
3534
|
+
* delegate to the unchecked {@link _do_enter_vector_edit} /
|
|
3535
|
+
* {@link _do_exit_vector_edit} helpers; the public wrappers below own
|
|
3536
|
+
* the history side, the unchecked helpers own the state mutation.
|
|
3537
|
+
*/
|
|
3538
|
+
enter_vector_edit(id) {
|
|
3539
|
+
if (this.vector_edit) return false;
|
|
3540
|
+
if (!this._do_enter_vector_edit(id)) return false;
|
|
3541
|
+
const internal = this.editor_internal();
|
|
3542
|
+
const node_id = id;
|
|
3543
|
+
const preview = internal.history.preview("enter vector edit");
|
|
3544
|
+
preview.set({
|
|
3545
|
+
providerId: "svg-editor",
|
|
3546
|
+
descriptor: { kind: "vector-mode-enter" },
|
|
3547
|
+
apply: () => {
|
|
3548
|
+
this._do_enter_vector_edit(node_id);
|
|
3549
|
+
},
|
|
3550
|
+
revert: () => {
|
|
3551
|
+
this._do_exit_vector_edit();
|
|
3552
|
+
}
|
|
3553
|
+
});
|
|
3554
|
+
preview.commit();
|
|
3555
|
+
return true;
|
|
3556
|
+
}
|
|
3557
|
+
/**
|
|
3558
|
+
* Discard any in-flight preview, clear the vector-edit session, and return
|
|
3559
|
+
* the editor to select mode. Safe to call when no vector-edit is active
|
|
3560
|
+
* (no-op). Idempotent.
|
|
3561
|
+
*
|
|
3562
|
+
* Pushes a `vector-mode-exit` history step that closes over the
|
|
3563
|
+
* session's `node_id` and its final sub-selection, so undo re-enters
|
|
3564
|
+
* the same path and restores the selection the user was about to
|
|
3565
|
+
* leave. Pairs with {@link enter_vector_edit}.
|
|
3566
|
+
*/
|
|
3567
|
+
exit_vector_edit() {
|
|
3568
|
+
if (!this.vector_edit) return;
|
|
3569
|
+
const node_id = this.vector_edit.node_id;
|
|
3570
|
+
const final_selection = this.vector_edit.snapshot_selection();
|
|
3571
|
+
this._do_exit_vector_edit();
|
|
3572
|
+
const preview = this.editor_internal().history.preview("exit vector edit");
|
|
3573
|
+
preview.set({
|
|
3574
|
+
providerId: "svg-editor",
|
|
3575
|
+
descriptor: { kind: "vector-mode-exit" },
|
|
3576
|
+
apply: () => {
|
|
3577
|
+
this._do_exit_vector_edit();
|
|
3578
|
+
},
|
|
3579
|
+
revert: () => {
|
|
3580
|
+
if (this._do_enter_vector_edit(node_id)) {
|
|
3581
|
+
this.vector_edit?.restore_selection(final_selection);
|
|
3582
|
+
this.sync_selection_mirror();
|
|
3583
|
+
this.redraw();
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
});
|
|
3587
|
+
preview.commit();
|
|
3588
|
+
}
|
|
3589
|
+
/**
|
|
3590
|
+
* Unchecked enter — performs the mode flip + HUD wiring without
|
|
3591
|
+
* pushing to history. Called by {@link enter_vector_edit} (user-facing,
|
|
3592
|
+
* which pushes the delta) and by the exit-delta's revert (history-
|
|
3593
|
+
* driven re-entry on undo). Returns `false` if the node has no usable
|
|
3594
|
+
* `d` attribute, leaving editor state untouched.
|
|
3595
|
+
*/
|
|
3596
|
+
_do_enter_vector_edit(id) {
|
|
3597
|
+
if (this.vector_edit) return false;
|
|
3598
|
+
const source = this.editor_internal().doc.is_vector_edit_target(id);
|
|
3599
|
+
if (source === null) return false;
|
|
3600
|
+
const session_d = source_to_session_d(source);
|
|
3601
|
+
this.vector_edit = new VectorEditSession(id, source, session_d);
|
|
3602
|
+
this.editor.commands.set_mode("edit-content");
|
|
3603
|
+
this.sync_selection_mirror();
|
|
3604
|
+
this.sync_surface_selection();
|
|
3605
|
+
this.sync_cursor();
|
|
3606
|
+
this.redraw();
|
|
3607
|
+
return true;
|
|
3608
|
+
}
|
|
3609
|
+
/**
|
|
3610
|
+
* Unchecked exit — counterpart to {@link _do_enter_vector_edit}. No
|
|
3611
|
+
* history push. Safe to call when no session is active.
|
|
3612
|
+
*/
|
|
3613
|
+
_do_exit_vector_edit() {
|
|
3614
|
+
if (!this.vector_edit) return;
|
|
3615
|
+
if (this.active_preview && (this.active_preview.kind === "vector_vertex_translate" || this.active_preview.kind === "vector_set_tangent" || this.active_preview.kind === "vector_bend_segment" || this.active_preview.kind === "vector_translate_selection")) {
|
|
3616
|
+
this.active_preview.session.discard();
|
|
3617
|
+
this.active_preview = null;
|
|
3618
|
+
}
|
|
3619
|
+
this.vector_edit = null;
|
|
3620
|
+
this.vector_edit_region_baseline = null;
|
|
3621
|
+
this.hud.setVectorSelection(null);
|
|
3622
|
+
if (this.current_tool.type === "lasso" || this.current_tool.type === "bend") this.editor.set_tool({ type: "cursor" });
|
|
3623
|
+
this.editor.commands.set_mode("select");
|
|
3624
|
+
this.sync_surface_selection();
|
|
3625
|
+
this.sync_cursor();
|
|
3626
|
+
this.redraw();
|
|
3627
|
+
}
|
|
3628
|
+
/**
|
|
3629
|
+
* `vectorOf` provider for the HUD. Returns the live PathSnapshot for the
|
|
3630
|
+
* named node when a vector-edit session is active for it; otherwise null.
|
|
3631
|
+
* HUD calls this each frame; cheap enough to recompute (snapshot just
|
|
3632
|
+
* copies the underlying network's arrays via the model's getter).
|
|
3633
|
+
*/
|
|
3634
|
+
vector_of(id) {
|
|
3635
|
+
if (!this.vector_edit || this.vector_edit.node_id !== id) return null;
|
|
3636
|
+
const model = this.active_preview_model_for(id) ?? this.session_model();
|
|
3637
|
+
if (!model) return null;
|
|
3638
|
+
const snap = model.snapshot();
|
|
3639
|
+
const el = this.element_index.get(id);
|
|
3640
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
3641
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
3642
|
+
const ctm = el.getScreenCTM();
|
|
3643
|
+
if (!ctm) return null;
|
|
3644
|
+
const cr = this.container.getBoundingClientRect();
|
|
3645
|
+
const offset = [-cr.left + this.container.scrollLeft, -cr.top + this.container.scrollTop];
|
|
3646
|
+
return {
|
|
3647
|
+
vertices: Array.from({ length: snap.vertices.length }, (_, i) => {
|
|
3648
|
+
const v = snap.vertices[i];
|
|
3649
|
+
return project_point_through_ctm(v[0], v[1], ctm, offset);
|
|
3650
|
+
}),
|
|
3651
|
+
segments: snap.segments.map((s) => {
|
|
3652
|
+
const va = snap.vertices[s.a];
|
|
3653
|
+
const vb = snap.vertices[s.b];
|
|
3654
|
+
const a_ctrl_local = [va[0] + s.ta[0], va[1] + s.ta[1]];
|
|
3655
|
+
const b_ctrl_local = [vb[0] + s.tb[0], vb[1] + s.tb[1]];
|
|
3656
|
+
return {
|
|
3657
|
+
a: s.a,
|
|
3658
|
+
b: s.b,
|
|
3659
|
+
a_control: project_point_through_ctm(a_ctrl_local[0], a_ctrl_local[1], ctm, offset),
|
|
3660
|
+
b_control: project_point_through_ctm(b_ctrl_local[0], b_ctrl_local[1], ctm, offset)
|
|
3661
|
+
};
|
|
3662
|
+
}),
|
|
3663
|
+
neighbours: model.neighbouringVertices({
|
|
3664
|
+
vertices: this.vector_edit.selected_vertices,
|
|
3665
|
+
segments: this.vector_edit.selected_segments,
|
|
3666
|
+
tangents: this.vector_edit.selected_tangents
|
|
3667
|
+
}),
|
|
3668
|
+
origin: [0, 0]
|
|
3669
|
+
};
|
|
3670
|
+
}
|
|
3671
|
+
/**
|
|
3672
|
+
* The session's in-memory PathModel-form `d`. For `<path>` sources
|
|
3673
|
+
* this stays in lock-step with `doc.get_attr(id, "d")`; for
|
|
3674
|
+
* `<line>` / `<polyline>` / `<polygon>` sources the document holds
|
|
3675
|
+
* native attrs and the session-d is the lingua-franca view that
|
|
3676
|
+
* gesture handlers parse from and write back to via
|
|
3677
|
+
* {@link apply_session_d}. Returns `null` if no session is active.
|
|
3678
|
+
*/
|
|
3679
|
+
read_session_d() {
|
|
3680
|
+
if (!this.vector_edit) return null;
|
|
3681
|
+
return this.vector_edit.current_d;
|
|
3682
|
+
}
|
|
3683
|
+
/**
|
|
3684
|
+
* Derive a fresh `PathModel` from the current `d` for the path under
|
|
3685
|
+
* edit. Computed on read — there is no cached copy held on the session.
|
|
3686
|
+
* See `vector-edit/session.ts` for the doctrine ("d is the live store").
|
|
3687
|
+
*/
|
|
3688
|
+
session_model() {
|
|
3689
|
+
const d = this.read_session_d();
|
|
3690
|
+
if (d === null) return null;
|
|
3691
|
+
return require_model.PathModel.fromSvgPathD(d);
|
|
3692
|
+
}
|
|
3693
|
+
/**
|
|
3694
|
+
* Republish the vector-edit sub-selection to the HUD. No-op when no
|
|
3695
|
+
* session is open. Every selection-changing handler ends with this so
|
|
3696
|
+
* the surface mirror stays in lock-step with `this.vector_edit` — the
|
|
3697
|
+
* inline `setVectorSelection({ node_id, vertices, segments, tangents })`
|
|
3698
|
+
* block was repeated at 6+ sites before this collapse.
|
|
3699
|
+
*/
|
|
3700
|
+
sync_selection_mirror() {
|
|
3701
|
+
if (!this.vector_edit) return;
|
|
3702
|
+
this.hud.setVectorSelection({
|
|
3703
|
+
node_id: this.vector_edit.node_id,
|
|
3704
|
+
vertices: this.vector_edit.selected_vertices,
|
|
3705
|
+
segments: this.vector_edit.selected_segments,
|
|
3706
|
+
tangents: this.vector_edit.selected_tangents
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
/**
|
|
3710
|
+
* Replay the session-side effects of a committed vector-edit delta onto
|
|
3711
|
+
* the LIVE `this.vector_edit` session — but only if it is still aimed at
|
|
3712
|
+
* the same node. Geometry restoration is the closure's `commit(d)` job
|
|
3713
|
+
* (document-level, always safe); this method handles the bits the
|
|
3714
|
+
* geometry write cannot reach on its own: advancing `last_seen_d` so
|
|
3715
|
+
* the external-mutation watcher doesn't pounce, and re-installing the
|
|
3716
|
+
* captured sub-selection.
|
|
3717
|
+
*
|
|
3718
|
+
* Closures used to call `session.mark_seen(...)` / `session.restore_selection(...)`
|
|
3719
|
+
* on a `const session = this.vector_edit` captured at gesture start.
|
|
3720
|
+
* After exit + undo-exit + undo-geometry, that capture pointed at the
|
|
3721
|
+
* disposed session while the live session was a fresh one — geometry
|
|
3722
|
+
* still restored correctly (via `commit`), but sub-selection didn't,
|
|
3723
|
+
* and the live session's stale `last_seen_d` would then cause the
|
|
3724
|
+
* watcher to clear the new session's selection on its next tick.
|
|
3725
|
+
*
|
|
3726
|
+
* Pass `d = null` for selection-only deltas (no geometry change → no
|
|
3727
|
+
* watermark advance).
|
|
3728
|
+
*/
|
|
3729
|
+
replay_vector_session_state(target_node_id, d, selection) {
|
|
3730
|
+
const cur = this.vector_edit;
|
|
3731
|
+
if (!cur || cur.node_id !== target_node_id) return;
|
|
3732
|
+
if (d !== null) {
|
|
3733
|
+
cur.mark_seen(d);
|
|
3734
|
+
const live_source = this.editor_internal().doc.is_vector_edit_target(cur.node_id);
|
|
3735
|
+
if (live_source) cur.sync_source(live_source);
|
|
3736
|
+
}
|
|
3737
|
+
cur.restore_selection(selection);
|
|
3738
|
+
this.sync_selection_mirror();
|
|
3739
|
+
}
|
|
3740
|
+
/**
|
|
3741
|
+
* Build the `{ apply, revert }` history step for a vector-edit geometry
|
|
3742
|
+
* delta — the single chokepoint that routes the write through
|
|
3743
|
+
* {@link vector_apply} / {@link vector_revert} so promote-to-path of a
|
|
3744
|
+
* primitive source (rect / circle / ellipse) is handled in one place
|
|
3745
|
+
* rather than per gesture.
|
|
3746
|
+
*
|
|
3747
|
+
* `promo` is the per-gesture token holder (shared by reference across an
|
|
3748
|
+
* `active_preview`'s preview frames and its committed step) so the
|
|
3749
|
+
* promotion that fires on the first frame is the one undo reverses —
|
|
3750
|
+
* promotion + first edit collapse into a single undo step. On redo after
|
|
3751
|
+
* an undo-demote, `apply` re-promotes and refreshes the token.
|
|
3752
|
+
*
|
|
3753
|
+
* `after_selection` / `before_selection` drive sub-selection replay;
|
|
3754
|
+
* pass `null` (preview frames) to skip it.
|
|
3755
|
+
*/
|
|
3756
|
+
vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection) {
|
|
3757
|
+
const internal = this.editor_internal();
|
|
3758
|
+
const doc = internal.doc;
|
|
3759
|
+
const emit = internal.emit;
|
|
3760
|
+
const session = this.vector_edit;
|
|
3761
|
+
return {
|
|
3762
|
+
providerId: "svg-editor",
|
|
3763
|
+
apply: () => {
|
|
3764
|
+
if (session) {
|
|
3765
|
+
const tok = vector_apply(doc, session, target_d);
|
|
3766
|
+
if (tok) promo.token = tok;
|
|
3767
|
+
}
|
|
3768
|
+
if (after_selection) this.replay_vector_session_state(node_id, target_d, after_selection);
|
|
3769
|
+
emit();
|
|
3770
|
+
},
|
|
3771
|
+
revert: () => {
|
|
3772
|
+
if (session) vector_revert(doc, session, baseline_d, promo.token);
|
|
3773
|
+
promo.token = null;
|
|
3774
|
+
if (before_selection) this.replay_vector_session_state(node_id, baseline_d, before_selection);
|
|
3775
|
+
emit();
|
|
3776
|
+
}
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3779
|
+
/**
|
|
3780
|
+
* Push a standalone vector sub-selection change as one history entry.
|
|
3781
|
+
*
|
|
3782
|
+
* Called by selection-only handlers (vertex / segment / tangent click,
|
|
3783
|
+
* marquee / lasso commit, clear-vector-selection) AFTER the
|
|
3784
|
+
* `VectorEditSession` has been mutated to the new state. `before` is the
|
|
3785
|
+
* snapshot captured before the mutation; the current session state is
|
|
3786
|
+
* captured here as `after`.
|
|
3787
|
+
*
|
|
3788
|
+
* Tagged with `descriptor: { kind: "vector-selection" }` so hosts that
|
|
3789
|
+
* want Figma-style "skip selection on undo" can filter on the
|
|
3790
|
+
* descriptor without inspecting closure internals. Default behavior:
|
|
3791
|
+
* standalone vector-selection IS undoable.
|
|
3792
|
+
*
|
|
3793
|
+
* No-op when the snapshot is unchanged — avoids spamming the stack
|
|
3794
|
+
* with entries from clicks that resolve to the same state (e.g.
|
|
3795
|
+
* clicking an already-selected vertex in `replace` mode).
|
|
3796
|
+
*/
|
|
3797
|
+
record_vector_selection_change(before, label) {
|
|
3798
|
+
if (!this.vector_edit) return;
|
|
3799
|
+
const after = this.vector_edit.snapshot_selection();
|
|
3800
|
+
if (sub_selection_equal(before, after)) return;
|
|
3801
|
+
const target_node_id = this.vector_edit.node_id;
|
|
3802
|
+
const preview = this.editor_internal().history.preview(label);
|
|
3803
|
+
preview.set({
|
|
3804
|
+
providerId: "svg-editor",
|
|
3805
|
+
descriptor: { kind: "vector-selection" },
|
|
3806
|
+
apply: () => {
|
|
3807
|
+
this.replay_vector_session_state(target_node_id, null, after);
|
|
3808
|
+
this.redraw();
|
|
3809
|
+
},
|
|
3810
|
+
revert: () => {
|
|
3811
|
+
this.replay_vector_session_state(target_node_id, null, before);
|
|
3812
|
+
this.redraw();
|
|
3813
|
+
}
|
|
3814
|
+
});
|
|
3815
|
+
preview.commit();
|
|
3816
|
+
}
|
|
3817
|
+
/** Resolve the in-flight `PathModel` for the named node id when a
|
|
3818
|
+
* vector-preview is active; null otherwise. */
|
|
3819
|
+
active_preview_model_for(id) {
|
|
3820
|
+
const ap = this.active_preview;
|
|
3821
|
+
if (!ap) return null;
|
|
3822
|
+
switch (ap.kind) {
|
|
3823
|
+
case "vector_vertex_translate": return ap.node_id === id ? ap.preview_model : null;
|
|
3824
|
+
case "vector_set_tangent": return ap.node_id === id ? ap.preview_model : null;
|
|
3825
|
+
case "vector_bend_segment": return ap.node_id === id ? ap.preview_model : null;
|
|
3826
|
+
case "vector_translate_selection": return ap.node_id === id ? ap.preview_model : null;
|
|
3827
|
+
default: return null;
|
|
3828
|
+
}
|
|
3829
|
+
}
|
|
3830
|
+
/**
|
|
3831
|
+
* Apply a `select_vertex` intent. Updates the vector-edit session's sub-
|
|
3832
|
+
* selection and pushes a fresh mirror to the HUD.
|
|
3833
|
+
*/
|
|
3834
|
+
handle_select_vertex(intent) {
|
|
3835
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
3836
|
+
const before = this.vector_edit.snapshot_selection();
|
|
3837
|
+
this.vector_edit.select_vertex(intent.index, intent.mode);
|
|
3838
|
+
this.sync_selection_mirror();
|
|
3839
|
+
this.redraw();
|
|
3840
|
+
this.record_vector_selection_change(before, "select vertex");
|
|
3841
|
+
}
|
|
3842
|
+
/**
|
|
3843
|
+
* Apply a `select_segment` intent. Mirrors {@link handle_select_vertex}.
|
|
3844
|
+
* Fired when the user clicks a segment OFF the ghost insertion knob —
|
|
3845
|
+
* clicking the ghost itself fires `split_segment` instead.
|
|
3846
|
+
*/
|
|
3847
|
+
handle_select_segment(intent) {
|
|
3848
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
3849
|
+
const before = this.vector_edit.snapshot_selection();
|
|
3850
|
+
this.vector_edit.select_segment(intent.segment, intent.mode);
|
|
3851
|
+
this.sync_selection_mirror();
|
|
3852
|
+
this.redraw();
|
|
3853
|
+
this.record_vector_selection_change(before, "select segment");
|
|
3854
|
+
}
|
|
3855
|
+
/**
|
|
3856
|
+
* Apply a `translate_vertices` intent. Mirrors the `set_endpoint` flow:
|
|
3857
|
+
* - First frame opens a `history.preview` session capturing the original `d`.
|
|
3858
|
+
* - Each subsequent preview frame applies a fresh translation FROM the
|
|
3859
|
+
* original baseline (so the cumulative delta on the intent stays correct
|
|
3860
|
+
* and we don't accumulate drift across frames).
|
|
3861
|
+
* - The commit frame finalizes the preview; the session keeps its updated
|
|
3862
|
+
* model for the next gesture.
|
|
3863
|
+
*
|
|
3864
|
+
* The vector-edit session's `model` is updated to reflect the committed
|
|
3865
|
+
* state (preview model is computed on the fly each frame from the
|
|
3866
|
+
* baseline; only commit writes back into session.model).
|
|
3867
|
+
*/
|
|
3868
|
+
handle_translate_vertices(intent) {
|
|
3869
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
3870
|
+
const internal = this.editor_internal();
|
|
3871
|
+
const node_id = intent.node_id;
|
|
3872
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_vertex_translate" || this.active_preview.node_id !== node_id || !require_model.array_shallow_equal(this.active_preview.indices, intent.indices)) {
|
|
3873
|
+
if (this.active_preview) {
|
|
3874
|
+
if ("session" in this.active_preview) this.active_preview.session.discard();
|
|
3875
|
+
}
|
|
3876
|
+
const initial_d = this.read_session_d();
|
|
3877
|
+
if (initial_d === null) return;
|
|
3878
|
+
const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
|
|
3879
|
+
this.active_preview = {
|
|
3880
|
+
kind: "vector_vertex_translate",
|
|
3881
|
+
node_id,
|
|
3882
|
+
promo: { token: null },
|
|
3883
|
+
indices: [...intent.indices],
|
|
3884
|
+
initial_d,
|
|
3885
|
+
baseline_model,
|
|
3886
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
3887
|
+
preview_model: baseline_model,
|
|
3888
|
+
session: internal.history.preview("vector/translate-vertex")
|
|
3889
|
+
};
|
|
3890
|
+
}
|
|
3891
|
+
const baseline_d = this.active_preview.initial_d;
|
|
3892
|
+
const indices = this.active_preview.indices;
|
|
3893
|
+
let local_dx = intent.dx;
|
|
3894
|
+
let local_dy = intent.dy;
|
|
3895
|
+
const el = this.element_index.get(node_id);
|
|
3896
|
+
if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
|
|
3897
|
+
const ctm = el.getScreenCTM();
|
|
3898
|
+
if (ctm) {
|
|
3899
|
+
if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
const preview_model = this.active_preview.baseline_model.translateVertices(indices, [local_dx, local_dy]);
|
|
3903
|
+
const target_d = preview_model.toSvgPathD();
|
|
3904
|
+
this.active_preview.preview_model = preview_model;
|
|
3905
|
+
if (intent.phase === "commit") {
|
|
3906
|
+
const before_selection = this.active_preview.before_selection;
|
|
3907
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
3908
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
3909
|
+
this.active_preview.session.commit();
|
|
3910
|
+
this.active_preview = null;
|
|
3911
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
3912
|
+
}
|
|
3913
|
+
/**
|
|
3914
|
+
* `translate_vector_selection` — the sub-selection-aware delta-translate.
|
|
3915
|
+
*
|
|
3916
|
+
* Mirrors main editor's `translate-vector-controls`
|
|
3917
|
+
* (`editor/grida-canvas/reducers/tools/event-target.cem-vector.reducer.ts:667-675`).
|
|
3918
|
+
* Translates the union of:
|
|
3919
|
+
*
|
|
3920
|
+
* - selected vertices (authoritative sub-selection)
|
|
3921
|
+
* - endpoints of selected segments (segment selection implies
|
|
3922
|
+
* its two endpoints translate)
|
|
3923
|
+
* - intent.additional_vertex_indices (carried by segment drag so
|
|
3924
|
+
* endpoints translate even
|
|
3925
|
+
* when the segment isn't yet
|
|
3926
|
+
* in sub-selection — the
|
|
3927
|
+
* deferred select_segment was
|
|
3928
|
+
* canceled by drag promotion)
|
|
3929
|
+
*
|
|
3930
|
+
* AND delta-translates selected tangents, EXCLUDING tangents whose parent
|
|
3931
|
+
* vertex is already in the translated set (mirrors `vector.ts:39-42`'s
|
|
3932
|
+
* `getUXNeighbouringVertices`-style exclusion: the vertex move already
|
|
3933
|
+
* carries its tangent controls, so double-applying would shift them
|
|
3934
|
+
* twice). Mirror policy pinned to `"none"` during multi-translate; mirror
|
|
3935
|
+
* behavior is reserved for the singleton-tangent curve gesture.
|
|
3936
|
+
*
|
|
3937
|
+
* Opens a dedicated `vector_translate_selection` preview so the
|
|
3938
|
+
* vertex translate AND tangent delta apply atomically per frame.
|
|
3939
|
+
*/
|
|
3940
|
+
handle_translate_vector_selection(intent) {
|
|
3941
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
3942
|
+
const internal = this.editor_internal();
|
|
3943
|
+
const node_id = intent.node_id;
|
|
3944
|
+
const ses = this.vector_edit;
|
|
3945
|
+
const current_d = this.read_session_d();
|
|
3946
|
+
if (current_d === null) return;
|
|
3947
|
+
const resolved_model = require_model.PathModel.fromSvgPathD(current_d);
|
|
3948
|
+
const vertex_count = resolved_model.vertexCount();
|
|
3949
|
+
const segment_snapshot = resolved_model.snapshot().segments;
|
|
3950
|
+
const indices_set = /* @__PURE__ */ new Set();
|
|
3951
|
+
const add_if_valid = (i) => {
|
|
3952
|
+
if (i >= 0 && i < vertex_count) indices_set.add(i);
|
|
3953
|
+
};
|
|
3954
|
+
for (const v of ses.selected_vertices) add_if_valid(v);
|
|
3955
|
+
for (const s of ses.selected_segments) {
|
|
3956
|
+
const seg = segment_snapshot[s];
|
|
3957
|
+
if (!seg) continue;
|
|
3958
|
+
add_if_valid(seg.a);
|
|
3959
|
+
add_if_valid(seg.b);
|
|
3960
|
+
}
|
|
3961
|
+
for (const v of intent.additional_vertex_indices) add_if_valid(v);
|
|
3962
|
+
const tangent_refs = [];
|
|
3963
|
+
for (const ref of ses.selected_tangents) {
|
|
3964
|
+
if (indices_set.has(ref[0])) continue;
|
|
3965
|
+
tangent_refs.push(ref);
|
|
3966
|
+
}
|
|
3967
|
+
if (indices_set.size === 0 && tangent_refs.length === 0) return;
|
|
3968
|
+
const indices = Array.from(indices_set).sort((a, b) => a - b);
|
|
3969
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_translate_selection" || this.active_preview.node_id !== node_id || !require_model.array_shallow_equal(this.active_preview.indices, indices) || !sameTangentRefs(this.active_preview.tangent_refs, tangent_refs)) {
|
|
3970
|
+
if (this.active_preview) {
|
|
3971
|
+
if ("session" in this.active_preview) this.active_preview.session.discard();
|
|
3972
|
+
}
|
|
3973
|
+
const initial_d = this.read_session_d();
|
|
3974
|
+
if (initial_d === null) return;
|
|
3975
|
+
const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
|
|
3976
|
+
const baseline_tangent_abs = tangent_refs.map((ref) => baseline_model.tangentAbsolute(ref, [0, 0]));
|
|
3977
|
+
this.active_preview = {
|
|
3978
|
+
kind: "vector_translate_selection",
|
|
3979
|
+
node_id,
|
|
3980
|
+
promo: { token: null },
|
|
3981
|
+
indices: [...indices],
|
|
3982
|
+
tangent_refs: [...tangent_refs],
|
|
3983
|
+
initial_d,
|
|
3984
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
3985
|
+
preview_model: baseline_model,
|
|
3986
|
+
baseline_model,
|
|
3987
|
+
baseline_tangent_abs,
|
|
3988
|
+
session: internal.history.preview("vector/translate-selection")
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3991
|
+
const baseline_d = this.active_preview.initial_d;
|
|
3992
|
+
let local_dx = intent.dx;
|
|
3993
|
+
let local_dy = intent.dy;
|
|
3994
|
+
const el = this.element_index.get(node_id);
|
|
3995
|
+
if (el instanceof SVGGraphicsElement && typeof el.getScreenCTM === "function") {
|
|
3996
|
+
const ctm = el.getScreenCTM();
|
|
3997
|
+
if (ctm) {
|
|
3998
|
+
if (ctm.a * ctm.d - ctm.c * ctm.b !== 0) [local_dx, local_dy] = project_delta_inverse_ctm(intent.dx, intent.dy, ctm);
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
const baseline_model = this.active_preview.baseline_model;
|
|
4002
|
+
const baseline_tangent_abs = this.active_preview.baseline_tangent_abs;
|
|
4003
|
+
let preview_model = indices.length > 0 ? baseline_model.translateVertices(indices, [local_dx, local_dy]) : baseline_model;
|
|
4004
|
+
for (let i = 0; i < tangent_refs.length; i++) {
|
|
4005
|
+
const baseline_abs = baseline_tangent_abs[i];
|
|
4006
|
+
if (baseline_abs === null) continue;
|
|
4007
|
+
preview_model = preview_model.setTangent(tangent_refs[i], [baseline_abs[0] + local_dx, baseline_abs[1] + local_dy], "none");
|
|
4008
|
+
}
|
|
4009
|
+
const target_d = preview_model.toSvgPathD();
|
|
4010
|
+
this.active_preview.preview_model = preview_model;
|
|
4011
|
+
if (intent.phase === "commit") {
|
|
4012
|
+
const before_selection = this.active_preview.before_selection;
|
|
4013
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
4014
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
4015
|
+
this.active_preview.session.commit();
|
|
4016
|
+
this.active_preview = null;
|
|
4017
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
4018
|
+
}
|
|
4019
|
+
/** Mirror handler for `select_tangent` (analogous to `select_vertex`). */
|
|
4020
|
+
handle_select_tangent(intent) {
|
|
4021
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4022
|
+
const before = this.vector_edit.snapshot_selection();
|
|
4023
|
+
this.vector_edit.select_tangent(intent.tangent, intent.mode);
|
|
4024
|
+
this.sync_selection_mirror();
|
|
4025
|
+
this.redraw();
|
|
4026
|
+
this.record_vector_selection_change(before, "select tangent");
|
|
4027
|
+
}
|
|
4028
|
+
/**
|
|
4029
|
+
* Tangent drag handler. Mirrors `handle_translate_vertices`:
|
|
4030
|
+
*
|
|
4031
|
+
* - First frame opens a `history.preview` session, captures `original_d`.
|
|
4032
|
+
* - Each preview frame replays setTangent from the baseline model so
|
|
4033
|
+
* cumulative drift never accumulates.
|
|
4034
|
+
* - Commit finalizes the preview and reseeds the session.
|
|
4035
|
+
*
|
|
4036
|
+
* `intent.pos` arrives in container CSS-px (HUD's doc-space). We project
|
|
4037
|
+
* it back through the inverse-CTM to path-local before calling
|
|
4038
|
+
* `PathModel.setTangent`.
|
|
4039
|
+
*/
|
|
4040
|
+
handle_set_tangent_intent(intent) {
|
|
4041
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4042
|
+
const internal = this.editor_internal();
|
|
4043
|
+
const node_id = intent.node_id;
|
|
4044
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_set_tangent" || this.active_preview.node_id !== node_id || this.active_preview.tangent[0] !== intent.tangent[0] || this.active_preview.tangent[1] !== intent.tangent[1]) {
|
|
4045
|
+
if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
|
|
4046
|
+
const initial_d = this.read_session_d();
|
|
4047
|
+
if (initial_d === null) return;
|
|
4048
|
+
const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
|
|
4049
|
+
this.active_preview = {
|
|
4050
|
+
kind: "vector_set_tangent",
|
|
4051
|
+
node_id,
|
|
4052
|
+
promo: { token: null },
|
|
4053
|
+
tangent: [intent.tangent[0], intent.tangent[1]],
|
|
4054
|
+
initial_d,
|
|
4055
|
+
baseline_model,
|
|
4056
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
4057
|
+
preview_model: baseline_model,
|
|
4058
|
+
session: internal.history.preview("vector/set-tangent")
|
|
4059
|
+
};
|
|
4060
|
+
}
|
|
4061
|
+
const baseline_d = this.active_preview.initial_d;
|
|
4062
|
+
const local_pos = this.project_doc_point_to_local(node_id, intent.pos);
|
|
4063
|
+
if (!local_pos) return;
|
|
4064
|
+
const preview_model = this.active_preview.baseline_model.setTangent(intent.tangent, local_pos, intent.mirror);
|
|
4065
|
+
const target_d = preview_model.toSvgPathD();
|
|
4066
|
+
this.active_preview.preview_model = preview_model;
|
|
4067
|
+
if (intent.phase === "commit") {
|
|
4068
|
+
const before_selection = this.active_preview.before_selection;
|
|
4069
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
4070
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
4071
|
+
this.active_preview.session.commit();
|
|
4072
|
+
this.active_preview = null;
|
|
4073
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
4074
|
+
}
|
|
4075
|
+
/**
|
|
4076
|
+
* Split a segment at parametric position `t`. One-shot atomic edit; no
|
|
4077
|
+
* preview phase. After the split, the new vertex is auto-selected — this
|
|
4078
|
+
* matches Figma's add-anchor behavior and prepares the user to immediately
|
|
4079
|
+
* drag the newly inserted anchor.
|
|
4080
|
+
*/
|
|
4081
|
+
handle_split_segment(intent) {
|
|
4082
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4083
|
+
const node_id = intent.node_id;
|
|
4084
|
+
const baseline_d = this.read_session_d();
|
|
4085
|
+
if (baseline_d === null) return;
|
|
4086
|
+
const { model: next_model, new_vertex } = require_model.PathModel.fromSvgPathD(baseline_d).splitSegment(intent.segment, intent.t);
|
|
4087
|
+
const target_d = next_model.toSvgPathD();
|
|
4088
|
+
const internal = this.editor_internal();
|
|
4089
|
+
const before_selection = this.vector_edit.snapshot_selection();
|
|
4090
|
+
const after_selection = Object.freeze({
|
|
4091
|
+
vertices: Object.freeze([new_vertex]),
|
|
4092
|
+
segments: Object.freeze([]),
|
|
4093
|
+
tangents: Object.freeze([])
|
|
4094
|
+
});
|
|
4095
|
+
const promo = { token: null };
|
|
4096
|
+
const split_session = internal.history.preview("vector/split-segment");
|
|
4097
|
+
split_session.set(this.vector_geometry_step(node_id, target_d, baseline_d, promo, after_selection, before_selection));
|
|
4098
|
+
split_session.commit();
|
|
4099
|
+
this.redraw();
|
|
4100
|
+
}
|
|
4101
|
+
/**
|
|
4102
|
+
* Bend a segment by dragging an interior point. Mirrors set_tangent —
|
|
4103
|
+
* preview session opened on first frame, replays from baseline each
|
|
4104
|
+
* frame, commits / reseeds on phase===commit.
|
|
4105
|
+
*
|
|
4106
|
+
* `frozen` is captured ONCE at session open from the baseline model;
|
|
4107
|
+
* `vne.bendSegment` solves for new tangents against this snapshot, so
|
|
4108
|
+
* the cumulative drag delta is correct without per-frame drift.
|
|
4109
|
+
*/
|
|
4110
|
+
handle_bend_segment(intent) {
|
|
4111
|
+
if (!this.vector_edit || this.vector_edit.node_id !== intent.node_id) return;
|
|
4112
|
+
const internal = this.editor_internal();
|
|
4113
|
+
const node_id = intent.node_id;
|
|
4114
|
+
if (!this.active_preview || this.active_preview.kind !== "vector_bend_segment" || this.active_preview.node_id !== node_id || this.active_preview.segment !== intent.segment || this.active_preview.ca !== intent.ca) {
|
|
4115
|
+
if (this.active_preview && "session" in this.active_preview) this.active_preview.session.discard();
|
|
4116
|
+
const initial_d = this.read_session_d();
|
|
4117
|
+
if (initial_d === null) return;
|
|
4118
|
+
const baseline_model = require_model.PathModel.fromSvgPathD(initial_d);
|
|
4119
|
+
const snap = baseline_model.snapshot();
|
|
4120
|
+
const s = snap.segments[intent.segment];
|
|
4121
|
+
if (!s) return;
|
|
4122
|
+
const va = snap.vertices[s.a];
|
|
4123
|
+
const vb = snap.vertices[s.b];
|
|
4124
|
+
this.active_preview = {
|
|
4125
|
+
kind: "vector_bend_segment",
|
|
4126
|
+
node_id,
|
|
4127
|
+
promo: { token: null },
|
|
4128
|
+
segment: intent.segment,
|
|
4129
|
+
ca: intent.ca,
|
|
4130
|
+
frozen: {
|
|
4131
|
+
a: [va[0], va[1]],
|
|
4132
|
+
b: [vb[0], vb[1]],
|
|
4133
|
+
ta: [s.ta[0], s.ta[1]],
|
|
4134
|
+
tb: [s.tb[0], s.tb[1]]
|
|
4135
|
+
},
|
|
4136
|
+
initial_d,
|
|
4137
|
+
baseline_model,
|
|
4138
|
+
before_selection: this.vector_edit.snapshot_selection(),
|
|
4139
|
+
preview_model: baseline_model,
|
|
4140
|
+
session: internal.history.preview("vector/bend-segment")
|
|
4141
|
+
};
|
|
4142
|
+
}
|
|
4143
|
+
const baseline_d = this.active_preview.initial_d;
|
|
4144
|
+
const frozen = this.active_preview.frozen;
|
|
4145
|
+
const local_cb = this.project_doc_point_to_local(node_id, intent.cb);
|
|
4146
|
+
if (!local_cb) return;
|
|
4147
|
+
const preview_model = this.active_preview.baseline_model.bendSegment(intent.segment, intent.ca, local_cb, frozen);
|
|
4148
|
+
const target_d = preview_model.toSvgPathD();
|
|
4149
|
+
this.active_preview.preview_model = preview_model;
|
|
4150
|
+
if (intent.phase === "commit") {
|
|
4151
|
+
const before_selection = this.active_preview.before_selection;
|
|
4152
|
+
const after_selection = this.vector_edit.snapshot_selection();
|
|
4153
|
+
this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, after_selection, before_selection));
|
|
4154
|
+
this.active_preview.session.commit();
|
|
4155
|
+
this.active_preview = null;
|
|
4156
|
+
} else this.active_preview.session.set(this.vector_geometry_step(node_id, target_d, baseline_d, this.active_preview.promo, null, null));
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Project a doc-space point (HUD's container CSS-px frame) back to the
|
|
4160
|
+
* named element's local frame via inverse-CTM. Returns null if no CTM
|
|
4161
|
+
* is available or the CTM is singular.
|
|
4162
|
+
*/
|
|
4163
|
+
project_doc_point_to_local(id, p) {
|
|
4164
|
+
const el = this.element_index.get(id);
|
|
4165
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
4166
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
4167
|
+
const ctm = el.getScreenCTM();
|
|
4168
|
+
if (!ctm) return null;
|
|
4169
|
+
const cr = this.container.getBoundingClientRect();
|
|
4170
|
+
const offset_x = -cr.left + this.container.scrollLeft;
|
|
4171
|
+
const offset_y = -cr.top + this.container.scrollTop;
|
|
4172
|
+
const det = ctm.a * ctm.d - ctm.c * ctm.b;
|
|
4173
|
+
if (det === 0) return null;
|
|
4174
|
+
const px = p[0] - offset_x;
|
|
4175
|
+
const py = p[1] - offset_y;
|
|
4176
|
+
return [(ctm.d * (px - ctm.e) - ctm.c * (py - ctm.f)) / det, (-ctm.b * (px - ctm.e) + ctm.a * (py - ctm.f)) / det];
|
|
4177
|
+
}
|
|
3266
4178
|
tag_of(id) {
|
|
3267
4179
|
return this.editor.tree().nodes.get(id)?.tag ?? "";
|
|
3268
4180
|
}
|
|
@@ -3295,25 +4207,8 @@ var DomSurface = class DomSurface {
|
|
|
3295
4207
|
bbox_world(id) {
|
|
3296
4208
|
const local = this.bbox_local(id);
|
|
3297
4209
|
if (!local) return null;
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
/** World-space rect for snap purposes. Differs from `bbox_world` for
|
|
3301
|
-
* `<svg>` viewport-establishing elements: `getBBox()` on an `<svg>`
|
|
3302
|
-
* reports the union of descendant geometry (SVG 2 §4.6.4), which —
|
|
3303
|
-
* when the dragged element is a descendant — silently turns the
|
|
3304
|
-
* dragged element's own pre-gesture position into a snap target via
|
|
3305
|
-
* the parent's edges. Use the viewport extent instead so the root
|
|
3306
|
-
* SVG's snap edges represent the canvas boundary, not "wherever the
|
|
3307
|
-
* children happen to be right now". */
|
|
3308
|
-
bbox_world_for_snap(id) {
|
|
3309
|
-
if (this.tag_of(id) === "svg") {
|
|
3310
|
-
const el = this.element_index.get(id);
|
|
3311
|
-
if (el instanceof SVGSVGElement) {
|
|
3312
|
-
const vp = svg_viewport_bounds(el);
|
|
3313
|
-
if (vp) return vp;
|
|
3314
|
-
}
|
|
3315
|
-
}
|
|
3316
|
-
return this.bbox_world(id);
|
|
4210
|
+
const transform_str = this.editor.document.get_attr(id, "transform");
|
|
4211
|
+
return require_model.transform.project(local, transform_str);
|
|
3317
4212
|
}
|
|
3318
4213
|
editor_internal() {
|
|
3319
4214
|
return this.editor._internal;
|
|
@@ -3322,13 +4217,92 @@ var DomSurface = class DomSurface {
|
|
|
3322
4217
|
function numAttr(doc, id, name) {
|
|
3323
4218
|
return _grida_svg_parse.svg_parse.parse_number(doc.get_attr(id, name));
|
|
3324
4219
|
}
|
|
4220
|
+
/** Order-sensitive shallow equality for tangent-ref arrays. */
|
|
4221
|
+
function sameTangentRefs(a, b) {
|
|
4222
|
+
if (a.length !== b.length) return false;
|
|
4223
|
+
for (let i = 0; i < a.length; i++) if (a[i][0] !== b[i][0] || a[i][1] !== b[i][1]) return false;
|
|
4224
|
+
return true;
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* Affine projection of a point through a 2×3 CTM, then offset by a
|
|
4228
|
+
* container origin (in page CSS-px). Mirrors how `line_endpoints_in_container`
|
|
4229
|
+
* and `shape_of` (transformed branch) bridge from local SVG coords to the
|
|
4230
|
+
* HUD's container-CSS-px space (HUD keeps its own transform at identity;
|
|
4231
|
+
* the SVG carries the camera as a CSS transform, which getScreenCTM
|
|
4232
|
+
* folds in).
|
|
4233
|
+
*
|
|
4234
|
+
* Exported for headless test coverage — pure function, no DOM types.
|
|
4235
|
+
*/
|
|
4236
|
+
function project_point_through_ctm(px, py, ctm, container_offset) {
|
|
4237
|
+
const [sx, sy] = _grida_cmath.default.vector2.transform([px, py], [[
|
|
4238
|
+
ctm.a,
|
|
4239
|
+
ctm.c,
|
|
4240
|
+
ctm.e
|
|
4241
|
+
], [
|
|
4242
|
+
ctm.b,
|
|
4243
|
+
ctm.d,
|
|
4244
|
+
ctm.f
|
|
4245
|
+
]]);
|
|
4246
|
+
return [sx + container_offset[0], sy + container_offset[1]];
|
|
4247
|
+
}
|
|
4248
|
+
/**
|
|
4249
|
+
* Inverse of the CTM's linear part applied to a delta vector. Drops
|
|
4250
|
+
* translation. Used to convert a HUD-reported container-space drag delta
|
|
4251
|
+
* back to the path's local coord space for `PathModel.translateVertices`.
|
|
4252
|
+
*
|
|
4253
|
+
* Throws on a degenerate (det = 0) matrix — the caller is expected to
|
|
4254
|
+
* have a non-singular CTM for any visible element.
|
|
4255
|
+
*/
|
|
4256
|
+
function project_delta_inverse_ctm(dx, dy, ctm) {
|
|
4257
|
+
if (ctm.a * ctm.d - ctm.c * ctm.b === 0) throw new Error("project_delta_inverse_ctm: singular CTM linear part");
|
|
4258
|
+
const inv = _grida_cmath.default.transform.invert([[
|
|
4259
|
+
ctm.a,
|
|
4260
|
+
ctm.c,
|
|
4261
|
+
0
|
|
4262
|
+
], [
|
|
4263
|
+
ctm.b,
|
|
4264
|
+
ctm.d,
|
|
4265
|
+
0
|
|
4266
|
+
]]);
|
|
4267
|
+
return _grida_cmath.default.vector2.transform([dx, dy], inv);
|
|
4268
|
+
}
|
|
4269
|
+
/**
|
|
4270
|
+
* Inverse-project a doc-space rect through a CTM + container offset back
|
|
4271
|
+
* into the element's local frame. The output is the AABB of the four
|
|
4272
|
+
* inverse-projected corners — when the CTM has a rotation component the
|
|
4273
|
+
* AABB is an approximation, but it matches what the user visually
|
|
4274
|
+
* expects from a screen-aligned marquee drag.
|
|
4275
|
+
*
|
|
4276
|
+
* Returns `null` when the CTM's linear part is singular (degenerate
|
|
4277
|
+
* camera) — the caller should skip any test that needs the local rect.
|
|
4278
|
+
*/
|
|
4279
|
+
function inverse_project_rect(rect, ctm, offset) {
|
|
4280
|
+
if (ctm.a * ctm.d - ctm.c * ctm.b === 0) return null;
|
|
4281
|
+
const inv = _grida_cmath.default.transform.invert([[
|
|
4282
|
+
ctm.a,
|
|
4283
|
+
ctm.c,
|
|
4284
|
+
ctm.e
|
|
4285
|
+
], [
|
|
4286
|
+
ctm.b,
|
|
4287
|
+
ctm.d,
|
|
4288
|
+
ctm.f
|
|
4289
|
+
]]);
|
|
4290
|
+
const to_local = (px, py) => _grida_cmath.default.vector2.transform([px - offset[0], py - offset[1]], inv);
|
|
4291
|
+
const corners = [
|
|
4292
|
+
to_local(rect.x, rect.y),
|
|
4293
|
+
to_local(rect.x + rect.width, rect.y),
|
|
4294
|
+
to_local(rect.x, rect.y + rect.height),
|
|
4295
|
+
to_local(rect.x + rect.width, rect.y + rect.height)
|
|
4296
|
+
];
|
|
4297
|
+
return _grida_cmath.default.rect.fromPoints(corners);
|
|
4298
|
+
}
|
|
3325
4299
|
/** World-space viewport rect of an `<svg>` element. Prefers `viewBox`
|
|
3326
4300
|
* (the declared user-space rect — what the user perceives as canvas),
|
|
3327
4301
|
* falls back to `width`/`height` at (0,0). For nested `<svg>` with a
|
|
3328
4302
|
* positional `x`/`y`, the declared viewBox/(0,0) is in the nested
|
|
3329
4303
|
* element's OWN user space; callers are responsible for CTM
|
|
3330
4304
|
* projection if a different frame is desired. v1 nested-svg story is
|
|
3331
|
-
* documented in docs/
|
|
4305
|
+
* documented in ../docs/geometry.md as out of scope. */
|
|
3332
4306
|
function svg_viewport_bounds(el) {
|
|
3333
4307
|
const vb = el.getAttribute("viewBox");
|
|
3334
4308
|
if (vb) {
|
|
@@ -3490,6 +4464,36 @@ var SvgGeometryDriver = class {
|
|
|
3490
4464
|
node_at_point(p) {
|
|
3491
4465
|
return this.accessors.pick_at_world(p, true);
|
|
3492
4466
|
}
|
|
4467
|
+
/** World→local delta projection. The frame an element's position is
|
|
4468
|
+
* written in is its PARENT user-space: a `<rect>`'s `x`/`y` and the
|
|
4469
|
+
* leading `translate(...)` composed onto a `<g>`/transformed node are
|
|
4470
|
+
* both interpreted there. We take the parent element's frame (not the
|
|
4471
|
+
* element's own) so that translating a node whose OWN transform has a
|
|
4472
|
+
* scale/rotation is not double-counted.
|
|
4473
|
+
*
|
|
4474
|
+
* Camera-free: `inv(root.getScreenCTM) ∘ parent.getScreenCTM` maps
|
|
4475
|
+
* parent user-space → root world-space, cancelling the shared CSS /
|
|
4476
|
+
* camera transform. Inverting its linear part turns a world delta into
|
|
4477
|
+
* the local delta. Identity (→ delta unchanged) for flat frames,
|
|
4478
|
+
* top-level nodes, and any degenerate / unavailable matrix. */
|
|
4479
|
+
world_delta_to_local(id, delta) {
|
|
4480
|
+
const parent = this.accessors.element_for(id)?.parentNode;
|
|
4481
|
+
const root = this.accessors.root();
|
|
4482
|
+
if (!(parent instanceof SVGGraphicsElement) || !root) return delta;
|
|
4483
|
+
if (parent === root) return delta;
|
|
4484
|
+
if (typeof parent.getScreenCTM !== "function" || typeof root.getScreenCTM !== "function") return delta;
|
|
4485
|
+
const parent_ctm = parent.getScreenCTM();
|
|
4486
|
+
const root_ctm = root.getScreenCTM();
|
|
4487
|
+
if (!parent_ctm || !root_ctm) return delta;
|
|
4488
|
+
const m = root_ctm.inverse().multiply(parent_ctm);
|
|
4489
|
+
const det = m.a * m.d - m.c * m.b;
|
|
4490
|
+
if (!Number.isFinite(det) || det === 0) return delta;
|
|
4491
|
+
const [x, y] = project_delta_inverse_ctm(delta.x, delta.y, m);
|
|
4492
|
+
return {
|
|
4493
|
+
x,
|
|
4494
|
+
y
|
|
4495
|
+
};
|
|
4496
|
+
}
|
|
3493
4497
|
};
|
|
3494
4498
|
var SvgHitShapeDriver = class {
|
|
3495
4499
|
constructor(accessors) {
|
|
@@ -3498,9 +4502,9 @@ var SvgHitShapeDriver = class {
|
|
|
3498
4502
|
hit_shape_of(id) {
|
|
3499
4503
|
const doc = this.accessors.doc();
|
|
3500
4504
|
if (!doc) return null;
|
|
3501
|
-
const intrinsic =
|
|
4505
|
+
const intrinsic = require_model.hit_shape_svg.of_doc(doc, id);
|
|
3502
4506
|
if (intrinsic) return intrinsic;
|
|
3503
|
-
if (
|
|
4507
|
+
if (require_model.hit_shape_svg.is_transparent_tag(doc.tag_of(id))) return null;
|
|
3504
4508
|
const bounds = this.accessors.bounds_of(id);
|
|
3505
4509
|
if (!bounds) return null;
|
|
3506
4510
|
return {
|
|
@@ -3543,3 +4547,21 @@ Object.defineProperty(exports, "attach_dom_surface", {
|
|
|
3543
4547
|
return attach_dom_surface;
|
|
3544
4548
|
}
|
|
3545
4549
|
});
|
|
4550
|
+
Object.defineProperty(exports, "inverse_project_rect", {
|
|
4551
|
+
enumerable: true,
|
|
4552
|
+
get: function() {
|
|
4553
|
+
return inverse_project_rect;
|
|
4554
|
+
}
|
|
4555
|
+
});
|
|
4556
|
+
Object.defineProperty(exports, "project_delta_inverse_ctm", {
|
|
4557
|
+
enumerable: true,
|
|
4558
|
+
get: function() {
|
|
4559
|
+
return project_delta_inverse_ctm;
|
|
4560
|
+
}
|
|
4561
|
+
});
|
|
4562
|
+
Object.defineProperty(exports, "project_point_through_ctm", {
|
|
4563
|
+
enumerable: true,
|
|
4564
|
+
get: function() {
|
|
4565
|
+
return project_point_through_ctm;
|
|
4566
|
+
}
|
|
4567
|
+
});
|