Merge pull request #4070 from marcoarnulfo/fix/direct-select-type-on-path

Direct Selection: an anchor of type on a path's path drags and the type reflows along it
This commit is contained in:
Brandon Thomas
2026-10-25 10:56:10 -04:00
committed by GitHub
7 changed files with 158 additions and 22 deletions
+21 -6
View File
@@ -1524,6 +1524,14 @@ impl TextKind {
}
}
/// Move anchors `refs` of `path`, in text space, by `d` in document space (`to_text`: the
/// document → text map). False when an anchor doesn't exist.
fn move_anchors(path: &mut PathData, to_text: Affine, refs: &[(usize, usize)], d: Vec2) -> bool {
// A distance: only the linear part of the document → text map applies.
let local = to_text * d.to_point() - to_text * Point::ZERO;
refs.iter().all(|&(si, ai)| path.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
}
/// `x` as a fraction of a path's length (0..1; 0 when not finite).
fn fraction(x: f64) -> f64 {
if x.is_finite() { x.clamp(0.0, 1.0) } else { 0.0 }
@@ -2008,21 +2016,28 @@ impl TextObject {
/// their handles (a Direct Selection drag of a frame corner or edge). False, changing
/// nothing, when an anchor doesn't exist, or as [`Self::transform_area`].
pub fn move_area_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
self.reshape_area(|frame, to_text| {
// A distance: only the linear part of the document → text map applies.
let local = to_text * d.to_point() - to_text * Point::ZERO;
refs.iter().all(|&(si, ai)| frame.anchor_mut(si, ai).map(|a| a.translate(local)).is_some())
})
self.reshape_area(|frame, to_text| move_anchors(frame, to_text, refs, d))
}
/// Move anchors `refs` (subpath, anchor) of type on a path's path by `d` in document space,
/// with their handles (a Direct Selection drag of an anchor); the brackets keep their
/// fractions of the path. False, changing nothing, for other type, when an anchor doesn't
/// exist, a degenerate `xf` or a path that would not be finite.
pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d))
}
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
/// when `f` succeeds and the result is finite.
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
matches!(self.kind, TextKind::Area { .. }) && self.reshape_local(f)
}
/// [`Self::reshape_area`] for the area frame or type on a path's path.
fn reshape_local(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
let det = self.xf.determinant();
if !det.is_finite() || det.abs() < 1e-12 {
return false;
}
let to_text = self.xf.inverse();
let TextKind::Area { frame } = &mut self.kind else { return false };
let (TextKind::Area { frame } | TextKind::OnPath { path: frame, .. }) = &mut self.kind else { return false };
let mut next = frame.clone();
if !f(&mut next, to_text) || !next.anchors().all(|(_, _, a)| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
return false;
+40
View File
@@ -97,6 +97,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
reshape_area
),
cmd!(
"text.reshapePath",
"Reshape Type Path",
[],
None,
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)",
has_doc,
reshape_path
),
cmd!(
"text.fitHeadline",
"Fit Headline",
@@ -1066,6 +1075,37 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.reshapePath";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let mut refs = super::select::checked_refs(p.get("anchors"), C)?;
refs.sort_unstable();
refs.dedup();
if refs.is_empty() {
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
}
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
let node = s.doc()?.doc.node(id);
let on_path = matches!(node.map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }));
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
return Err(bad(C, "not type on a path outside perspective"));
}
s.edit("Reshape Type Path", |doc, _| {
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let before = t.kind.clone();
let within = |t: &TextObject| {
t.type_path().and_then(|p| p.bounds()).is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
};
if !(t.move_path_anchors(&refs, d) && within(t)) {
t.kind = before;
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
}
refresh_bounds(t);
Ok(())
})?;
ok()
}
#[cfg(test)]
mod area_tests {
use super::*;
+25
View File
@@ -252,3 +252,28 @@ fn dragging_the_start_and_end_brackets_sets_where_the_type_runs() {
let r = ink(&s, id);
assert!(r.x0 > 199.0 && r.x1 < 401.0, "between the brackets: {r:?}");
}
/// #3980: Direct Selection drags an anchor of type on a path's path, in document space through
/// the type's transform; the brackets keep their fractions of the path and the type reflows.
#[test]
fn direct_selection_drags_an_anchor_of_the_path_and_the_type_reflows() {
let mut s = session();
let id = path_type(&mut s, "M100 300 L300 300 L500 300", [100.0, 310.0], "Reflowing type");
s.execute("type.pathOptions", &json!({"start": 0.0, "end": 0.75})).unwrap();
// A quarter turn, so a document-space move isn't the path's own.
s.execute("object.transform", &json!({"ids": [id.0], "matrix": [0, 1, -1, 0, 600, 0]})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let path = |s: &Session| text(s, id).type_path().unwrap();
let (first, middle) = (path(&s).subpaths[0].anchors[0].p, path(&s).subpaths[0].anchors[1].p);
let (glyphs, n) = (origins(&s, id), undo_len(&s));
drag(&mut s, "directSelection", middle, middle + vectorcraft_geom::Vec2::new(40.0, 0.0), Mods::default());
let after = path(&s);
let moved = after.subpaths[0].anchors[1].p;
assert!(near(moved.x, middle.x + 40.0) && near(moved.y, middle.y), "{middle:?} → {moved:?}");
assert_eq!(after.subpaths[0].anchors[0].p, first, "the other anchors stay");
assert_eq!(brackets(&s, id), (0.0, Some(0.75)));
assert_ne!(origins(&s, id), glyphs, "the type reflows along the reshaped path");
assert_eq!((undo_len(&s), last_step(&s).as_str()), (n + 1, "Reshape Type Path"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(path(&s).subpaths[0].anchors[1].p, middle);
}
+62 -15
View File
@@ -23,7 +23,8 @@
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
//! mesh's point last clicked). Dragging a corner or an edge of area
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size; dragging
//! an anchor of type on a path's path reshapes the path (`text.reshapePath`): the text reflows along it.
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
//!
//! A press on the stroke of a path that isn't selected as a whole selects that segment's two anchors
@@ -43,7 +44,7 @@ use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
use crate::meshedit::MeshEdit;
use crate::pathtype::{self, BracketDrag, over_bracket};
use crate::rulerguide::GuideEdit;
use crate::select::{MoveSnap, hold_point, is_area_type, matrix_json, release_guide_at};
use crate::select::{MoveSnap, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Debug)]
@@ -120,7 +121,8 @@ enum State {
},
/// Dragging a mesh point or handle ([`MeshEdit`]).
Mesh,
/// Dragging anchors of area type's frame (a corner, or the two ends of an edge).
/// Dragging anchors of area type's frame (a corner, or the two ends of an edge), or an anchor
/// of type on a path's path.
TypeArea {
id: NodeId,
anchors: Vec<AnchorRef>,
@@ -274,11 +276,12 @@ fn spine_anchor(cx: &ToolContext, (id, ai): (NodeId, usize)) -> Option<vectorcra
}
}
/// The anchors Direct Selection edits on `n`, in document space: a path's, or area type's frame.
/// The anchors Direct Selection edits on `n`, in document space: a path's, area type's frame or
/// type on a path's path.
fn editable_path(n: &Node) -> Option<Cow<'_, PathData>> {
match &n.kind {
NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)),
NodeKind::Text(t) => t.area_frame().map(Cow::Owned),
NodeKind::Text(t) => t.area_frame().or_else(|| t.type_path()).map(Cow::Owned),
_ => None,
}
}
@@ -303,7 +306,7 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec<NodeId> {
/// [`hovered_anchor`] marks it. In normal viewmode, an opaque fill in front occludes unselected
/// objects behind it (#3405).
fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> {
let mut owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n));
let mut owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n) || is_path_type(n));
owners.sort_by_key(|id| !cx.selection.contains(*id));
// 1. Check selected objects first. Selected objects are NEVER occluded (#1508).
@@ -350,6 +353,13 @@ fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, us
})
}
/// Is `p` within a press's reach of an anchor of type on a path's path? The anchor wins over a
/// bracket standing on it (the end bracket on the last anchor, #3980); the bracket's stem above it
/// still drags the bracket.
fn over_path_type_anchor(cx: &ToolContext, p: Point) -> bool {
hit_anchor(cx, p, cx.point_tol()).is_some_and(|(id, ..)| cx.doc.node(id).is_some_and(is_path_type))
}
/// The anchor of `pd` within `tol` of `p` nearest to it: (subpath, anchor, where it is).
fn nearest_anchor(pd: &PathData, p: Point, tol: f64) -> Option<(usize, usize, Point)> {
pd.anchors().map(|(si, ai, a)| (si, ai, a.p)).filter(|(.., a)| a.distance(p) <= tol).min_by(|x, y| x.2.distance(p).total_cmp(&y.2.distance(p)))
@@ -548,7 +558,9 @@ impl Tool for DirectSelectionTool {
self.state = State::Corner(c);
return vec![];
}
if let Some(b) = BracketDrag::hit(cx, ev) {
if !over_path_type_anchor(cx, p)
&& let Some(b) = BracketDrag::hit(cx, ev)
{
self.state = State::Bracket(b);
return vec![];
}
@@ -576,7 +588,7 @@ impl Tool for DirectSelectionTool {
return if cx.selection.contains(id) { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] };
}
if let Some((id, si, ai, grab)) = hit_anchor(cx, p, point) {
if cx.doc.node(id).is_some_and(is_area_type) {
if cx.doc.node(id).is_some_and(|n| is_area_type(n) || is_path_type(n)) {
return self.press_type_area(cx, id, vec![(si, ai)], p, ev.mods.shift);
}
// Alt picks the whole path (and Alt-drag copies it), as on a segment.
@@ -744,14 +756,16 @@ impl Tool for DirectSelectionTool {
}
(PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => {
let mut out = vec![];
let on_path = cx.doc.node(id).is_some_and(is_path_type);
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Reshape Type Area".into()));
out.push(Action::Begin(if on_path { "Reshape Type Path" } else { "Reshape Type Area" }.into()));
}
let d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
out.push(Action::Preview("text.reshapeArea".into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y})));
let cmd = if on_path { "text.reshapePath" } else { "text.reshapeArea" };
out.push(Action::Preview(cmd.into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y})));
self.state = State::TypeArea { id, anchors, start, began: true };
out
}
@@ -926,7 +940,7 @@ impl Tool for DirectSelectionTool {
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p, true)) {
return Cursor::CornerRadius;
}
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p)) {
if !self.group && (matches!(self.state, State::Bracket(_)) || over_bracket(cx, p) && !over_path_type_anchor(cx, p)) {
return Cursor::PathBracket;
}
let rest = if self.group { Cursor::GroupSelection } else { Cursor::ArrowHollow };
@@ -1020,11 +1034,12 @@ fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> {
})
}
/// The frame anchors of the selected area type, which Direct Selection drags.
/// The frame anchors of the selected area type and the path anchors of the selected type on a
/// path, which Direct Selection drags.
fn frame_overlays(cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
for id in &cx.selection.objects {
let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n)).and_then(editable_path) else { continue };
let Some(frame) = cx.doc.node(*id).filter(|n| is_area_type(n) || is_path_type(n)).and_then(editable_path) else { continue };
let color = cx.doc.layer_color(*id);
out.extend(frame.anchors().map(|(_, _, a)| Overlay::Anchor { p: a.p, color, filled: false, size: 5.0 }));
}
@@ -1032,8 +1047,8 @@ fn frame_overlays(cx: &ToolContext) -> Vec<Overlay> {
}
impl DirectSelectionTool {
/// Press on area type's frame anchors (a corner, or an edge's two ends): select the text and
/// get ready to drag them.
/// Press on area type's frame anchors (a corner, or an edge's two ends) or an anchor of type
/// on a path's path: select the text and get ready to drag them.
fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, anchors: Vec<AnchorRef>, p: Point, shift: bool) -> Vec<Action> {
self.state = State::TypeArea { id, anchors, start: p, began: false };
if cx.selection.contains(id) {
@@ -1335,6 +1350,38 @@ mod tests {
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4);
}
/// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last
/// one too although the end bracket stands on it; the bracket's stem above it drags the bracket.
#[test]
fn dragging_an_anchor_of_type_on_a_path_reshapes_its_path() {
let (d, text) = doc_with_path_type();
let p = paint();
let s = Selection::default();
let cx1 = cx(&d, &s, &p);
let mut t = DirectSelectionTool::new(false);
assert_eq!(
t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 101.0, 301.0)),
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))]
);
let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 121.0, 261.0));
assert_eq!(a[0], Action::Begin("Reshape Type Path".into()));
assert_eq!(a[1], Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 0]], "dx": 20.0, "dy": -40.0})));
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 121.0, 261.0)), vec![Action::Commit]);
let mut s = Selection::default();
s.add(text);
let cx2 = cx(&d, &s, &p);
let anchors: Vec<Point> =
t.overlays(&cx2).iter().filter_map(|o| if let Overlay::Anchor { p, filled: false, .. } = o { Some(*p) } else { None }).collect();
assert_eq!(anchors, vec![Point::new(100.0, 300.0), Point::new(400.0, 300.0)]);
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 300.0)).is_empty());
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 400.0, 260.0));
assert_eq!(a.last(), Some(&Action::Preview("text.reshapePath".into(), json!({"id": text.0, "anchors": [[0, 1]], "dx": 0.0, "dy": -40.0}))));
t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 400.0, 260.0));
t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 290.0));
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 340.0, 290.0));
assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "type.pathOptions"), "{a:?}");
}
#[test]
fn click_anchor_selects_it() {
let (d, id) = doc_with_rect();
+6
View File
@@ -1014,6 +1014,12 @@ pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool {
&& matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }))
}
/// Is `n` type on a path outside perspective (Direct Selection drags its path's anchors)?
pub(crate) fn is_path_type(n: &vectorcraft_doc::Node) -> bool {
n.extras().perspective.is_none()
&& matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }))
}
/// Can `n` be a frame of a thread: area type or type on a path, outside perspective?
pub(crate) fn is_threadable(n: &vectorcraft_doc::Node) -> bool {
n.extras().perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if !matches!(t.kind, vectorcraft_doc::TextKind::Point))
+3
View File
@@ -4131,6 +4131,9 @@ text at its size, through its thread too. `text.reshapeArea {id, anchors: [[subp
frame anchors with their handles (Direct Selection dragging a corner, or an edge's two ends), and
`text.areaOptions {width?, height?}` sizes the area from its top-left corner (the query returns both). Each is one
undo step. Object › Transform › Scale, the Scale tool and the Transform panel still scale the type.
`text.reshapePath {id, anchors: [[subpath, anchor]…], dx, dy}` does the same for the path of type on a path
(Direct Selection dragging one of its anchors): the start and end brackets keep their fractions of the path and the
text reflows along it.
```json
{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}}
File diff suppressed because one or more lines are too long