mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
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:
+21
-6
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user