mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Merge pull request #4435 from dotCooCoo/feat/area-type-anchor-marquee
Direct Selection: a marquee selects area type's frame anchors
This commit is contained in:
@@ -96,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge)",
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
@@ -1063,22 +1063,45 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.reshapeArea";
|
||||
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]…]"));
|
||||
// One area type (`id`, `anchors`), or several (`items`: [{id, anchors}…]).
|
||||
let raw: Vec<&Value> = match p.get("items") {
|
||||
Some(items) => {
|
||||
if p.get("id").is_some() || p.get("anchors").is_some() {
|
||||
return Err(bad(C, "give `id` and `anchors`, or `items`, not both"));
|
||||
}
|
||||
let items = items.as_array().ok_or_else(|| bad(C, "`items` must be an array of {id, anchors}"))?;
|
||||
if items.is_empty() {
|
||||
return Err(bad(C, "`items` is empty"));
|
||||
}
|
||||
items.iter().collect()
|
||||
}
|
||||
None => vec![p],
|
||||
};
|
||||
let mut items = Vec::with_capacity(raw.len());
|
||||
for it in raw {
|
||||
let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut refs = super::select::checked_refs(it.get("anchors"), C)?;
|
||||
refs.sort_unstable();
|
||||
refs.dedup();
|
||||
if refs.is_empty() {
|
||||
return Err(bad(C, "missing anchors [[subpath, anchor]…]"));
|
||||
}
|
||||
if items.iter().any(|(other, _)| *other == id) {
|
||||
return Err(bad(C, format!("area type {} is named twice", id.0)));
|
||||
}
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
items.push((id, refs));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
let node = s.doc()?.doc.node(id);
|
||||
if area_text(node).is_none() || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not area type (text in a frame) outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(&refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
for (id, refs) in &items {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use vectorcraft_doc::{NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_text::TextLayout;
|
||||
use vectorcraft_tools::bbox::Handle;
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -194,6 +194,87 @@ fn direct_selection_drags_a_frame_corner_or_edge() {
|
||||
assert_eq!(text(&s, id).runs[0].style.size, 12.0);
|
||||
}
|
||||
|
||||
/// Direct Selection's marquee selects the frame anchors of area type inside it: a drag of one moves
|
||||
/// only those, the arrow keys nudge them, and Shift-click takes one out.
|
||||
#[test]
|
||||
fn direct_selection_marquee_selects_frame_anchors() {
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let other = area(&mut s, 300.0, 40.0, 120.0, 40.0, STORY);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
// A marquee round the right-hand corners, (160, 40) and (160, 80).
|
||||
drag(&mut s, "directSelection", Point::new(150.0, 20.0), Point::new(180.0, 100.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, "directSelection", Point::new(160.0, 80.0), Point::new(180.0, 100.0));
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(last_step(&s), "Reshape Type Area");
|
||||
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(140.0, 20.0), Point::new(140.0, 60.0), Point::new(0.0, 40.0)]);
|
||||
// The arrow keys nudge them, each press one step, and Shift-click takes (179, 60) out.
|
||||
assert!(s.tool_claims_key(ToolKey::Left, v));
|
||||
s.tool_key(ToolKey::Left, Mods::default(), v).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(139.0, 20.0), Point::new(139.0, 60.0), Point::new(0.0, 40.0)]);
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 179.0, 60.0).with_mods(shift), v).unwrap();
|
||||
}
|
||||
s.tool_key(ToolKey::Down, shift, v).unwrap();
|
||||
assert_eq!(corners(&s, id), vec![Point::ZERO, Point::new(139.0, 20.0), Point::new(139.0, 70.0), Point::new(0.0, 40.0)]);
|
||||
// A marquee across both frames' facing corners moves both frames in one step.
|
||||
drag(&mut s, "directSelection", Point::new(170.0, 10.0), Point::new(310.0, 65.0));
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, "directSelection", Point::new(300.0, 40.0), Point::new(300.0, 30.0));
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(corners(&s, id)[1], Point::new(139.0, 10.0));
|
||||
assert_eq!(corners(&s, other), vec![Point::new(0.0, -10.0), Point::new(120.0, 0.0), Point::new(120.0, 40.0), Point::new(0.0, 40.0)]);
|
||||
// Shift-click on the other frame's only selected corner deselects that type; the corner of the
|
||||
// first frame still nudges.
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 300.0, 30.0).with_mods(shift), v).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
s.tool_key(ToolKey::Up, Mods::default(), v).unwrap();
|
||||
assert_eq!(corners(&s, id)[1], Point::new(139.0, 9.0));
|
||||
// Deselect, then the same two selected again: they are selected whole, so the arrow keys move
|
||||
// them as objects.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
sel(&mut s, &[id, other]);
|
||||
assert!(!s.tool_claims_key(ToolKey::Left, v));
|
||||
// A click on empty canvas deselects; the arrow keys are then no longer the tool's.
|
||||
drag(&mut s, "directSelection", Point::new(600.0, 500.0), Point::new(600.0, 500.0));
|
||||
assert!(!s.tool_claims_key(ToolKey::Left, v));
|
||||
}
|
||||
|
||||
/// `text.reshapeArea` with `items` moves several frames' anchors in one step, and fails whole.
|
||||
#[test]
|
||||
fn reshape_area_moves_the_anchors_of_several_frames() {
|
||||
let mut s = session();
|
||||
let a = area(&mut s, 40.0, 40.0, 120.0, 40.0, STORY);
|
||||
let b = area(&mut s, 300.0, 40.0, 120.0, 40.0, STORY);
|
||||
let n = undo_len(&s);
|
||||
let items = json!([{"id": a.0, "anchors": [[0, 2]]}, {"id": b.0, "anchors": [[0, 0], [0, 3]]}]);
|
||||
s.execute("text.reshapeArea", &json!({"items": items, "dx": -10, "dy": 5})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "one step");
|
||||
assert_eq!(corners(&s, a)[2], Point::new(110.0, 45.0));
|
||||
assert_eq!(corners(&s, b), vec![Point::new(-10.0, 5.0), Point::new(120.0, 0.0), Point::new(120.0, 40.0), Point::new(-10.0, 45.0)]);
|
||||
for bad in [
|
||||
json!({"items": [], "dx": 1, "dy": 1}),
|
||||
json!({"items": {"id": a.0}, "dx": 1, "dy": 1}),
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}], "id": a.0, "dx": 1, "dy": 1}),
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"id": a.0, "anchors": [[0, 1]]}], "dx": 1, "dy": 1}),
|
||||
// A bad item fails the whole step: the good one does not move either.
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"id": b.0, "anchors": [[0, 9]]}], "dx": 1, "dy": 1}),
|
||||
json!({"items": [{"id": a.0, "anchors": [[0, 2]]}, {"anchors": [[0, 1]]}], "dx": 1, "dy": 1}),
|
||||
] {
|
||||
assert!(s.execute("text.reshapeArea", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1, "nothing more changed");
|
||||
assert_eq!(corners(&s, a)[2], Point::new(110.0, 45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_frame_reflows_its_thread() {
|
||||
let mut s = session();
|
||||
|
||||
+228
-31
@@ -27,8 +27,11 @@
|
||||
//! 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; dragging
|
||||
//! an anchor of type on a path's path reshapes the path (`text.reshapePath`): the text reflows along it.
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size. A click
|
||||
//! on a frame anchor or a marquee selects frame anchors (Shift toggles them). While the selection
|
||||
//! holds only area type with selected frame anchors, those show filled, a drag of one of them moves
|
||||
//! them all, and the arrow keys nudge them; otherwise the type is selected whole. 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
|
||||
@@ -36,6 +39,7 @@
|
||||
//! moves its anchors.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test, selected_hidden_at};
|
||||
@@ -125,16 +129,34 @@ 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), or an anchor
|
||||
/// of type on a path's path.
|
||||
/// Dragging anchors of area type's frames (a corner, the two ends of an edge, or the selected
|
||||
/// frame anchors), or an anchor of type on a path's path: each object with its anchors.
|
||||
TypeArea {
|
||||
id: NodeId,
|
||||
anchors: Vec<AnchorRef>,
|
||||
items: FrameAnchors,
|
||||
start: Point,
|
||||
began: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Anchors of the frames of area type (or of the path of type on a path), each object with its
|
||||
/// anchors.
|
||||
type FrameAnchors = Vec<(NodeId, Vec<AnchorRef>)>;
|
||||
|
||||
/// What [`DirectSelectionTool::notify`] hears after a press or a marquee selected frame anchors:
|
||||
/// the selected objects to keep them with.
|
||||
const FRAME_PICKED: &str = "frameAnchors";
|
||||
|
||||
/// The params of `text.reshapeArea` or `text.reshapePath` moving `items` by `d`.
|
||||
fn reshape_params(items: &[(NodeId, Vec<AnchorRef>)], d: Vec2) -> Value {
|
||||
match items {
|
||||
[(id, anchors)] => json!({"id": id.0, "anchors": anchors_json(anchors), "dx": d.x, "dy": d.y}),
|
||||
_ => {
|
||||
let items: Vec<Value> = items.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
json!({"items": items, "dx": d.x, "dy": d.y})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far (pt) a snap must move a dragged point for the arrow to say it snapped.
|
||||
const SNAPPED: f64 = 1e-9;
|
||||
|
||||
@@ -182,6 +204,15 @@ pub struct DirectSelectionTool {
|
||||
/// An Alt-click on a path selected whole adds this enclosing group on release, as a Group
|
||||
/// Selection click does; an Alt-drag still copies the selection.
|
||||
click_group: Option<NodeId>,
|
||||
/// The selected anchors of area type's frames, picked by a click or a marquee: they show
|
||||
/// filled, a drag of one moves them all, and the arrow keys nudge them. They stay selected
|
||||
/// while the document and its selection are at `frame_revision`, the revision after they were
|
||||
/// picked or the tool last moved them.
|
||||
frame: BTreeMap<NodeId, BTreeSet<AnchorRef>>,
|
||||
frame_revision: (u64, u64),
|
||||
/// The already selected frame anchor the press landed on without Shift: a click (no drag)
|
||||
/// selects it alone, as a drag moves every selected frame anchor with it.
|
||||
frame_click: Option<(NodeId, AnchorRef)>,
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
@@ -204,9 +235,67 @@ impl DirectSelectionTool {
|
||||
click: None,
|
||||
click_segment: None,
|
||||
click_group: None,
|
||||
frame: BTreeMap::new(),
|
||||
frame_revision: (0, 0),
|
||||
frame_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected frame anchors that still apply, each object with its anchors. They apply while
|
||||
/// nothing else changed the document or the selection since they were picked (a drag of them
|
||||
/// changes it as it goes), and only while the selection holds nothing else: every selected
|
||||
/// object is editable area type with selected frame anchors, and no path anchors or ruler
|
||||
/// guides are selected. Otherwise the type is selected whole.
|
||||
fn frame_anchors(&self, cx: &ToolContext) -> FrameAnchors {
|
||||
let dragging = matches!(self.state, State::TypeArea { began: true, .. });
|
||||
if self.group
|
||||
|| self.frame.is_empty()
|
||||
|| (!dragging && cx.revision != self.frame_revision)
|
||||
|| !cx.selection.anchors.is_empty()
|
||||
|| !cx.selection.guides.is_empty()
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
let editable = cx.doc.editable();
|
||||
let v: FrameAnchors = self
|
||||
.frame
|
||||
.iter()
|
||||
.filter_map(|(id, set)| {
|
||||
let n = cx
|
||||
.doc
|
||||
.node(*id)
|
||||
.filter(|n| cx.selection.contains(*id) && is_area_type(n) && editable(*id) && in_scope(cx.doc, cx.isolation, *id))?;
|
||||
let frame = editable_path(n)?;
|
||||
let v: Vec<AnchorRef> =
|
||||
set.iter().copied().filter(|(si, ai)| frame.subpaths.get(*si).is_some_and(|sp| *ai < sp.anchors.len())).collect();
|
||||
(!v.is_empty()).then_some((*id, v))
|
||||
})
|
||||
.collect();
|
||||
if v.len() == cx.selection.objects.len() { v } else { vec![] }
|
||||
}
|
||||
|
||||
/// The move an arrow key nudges the selected frame anchors by (General › Keyboard Increment,
|
||||
/// ten times with Shift, along General › Constrain Angle's axes), with those anchors.
|
||||
fn frame_nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<(FrameAnchors, Vec2)> {
|
||||
if !matches!(self.state, State::Idle) {
|
||||
return None;
|
||||
}
|
||||
let step = cx.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
|
||||
let d = match key {
|
||||
ToolKey::Left => Point::new(-step, 0.0),
|
||||
ToolKey::Right => Point::new(step, 0.0),
|
||||
ToolKey::Up => Point::new(0.0, -step),
|
||||
ToolKey::Down => Point::new(0.0, step),
|
||||
_ => return None,
|
||||
};
|
||||
let items = self.frame_anchors(cx);
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let d = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(d, |axes| axes * d);
|
||||
Some((items, d.to_vec2()))
|
||||
}
|
||||
|
||||
/// Is `key` the E that Group Selection takes while a marquee is being dragged? Direct
|
||||
/// Selection takes no letter.
|
||||
fn claims_letter(&self, key: ToolKey) -> bool {
|
||||
@@ -492,7 +581,11 @@ impl Tool for DirectSelectionTool {
|
||||
/// goes back and the tool waits for the next press (#1743).
|
||||
/// With Group Selection, E during a marquee drag makes the marquee select only the objects
|
||||
/// entirely inside it, whole, and E pressed again makes it select what it selects by default.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
/// The arrow keys nudge the selected frame anchors of area type.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if let Some((items, d)) = self.frame_nudge(cx, key, mods) {
|
||||
return vec![Action::Exec("text.reshapeArea".into(), reshape_params(&items, d)), Action::Notify(FRAME_PICKED.into())];
|
||||
}
|
||||
if self.claims_letter(key)
|
||||
&& let State::Marquee { enclosed, .. } = &mut self.state
|
||||
{
|
||||
@@ -507,14 +600,21 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
// A mesh point drag ends here too; the `Action::Cancel` below undoes it.
|
||||
self.mesh.cancel();
|
||||
// The frame anchors a cancelled drag held stay selected.
|
||||
let frame = matches!(self.state, State::TypeArea { .. }) && !self.frame.is_empty();
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
(self.click, self.click_segment, self.click_group) = (None, None, None);
|
||||
vec![Action::Cancel]
|
||||
(self.click, self.click_segment, self.click_group, self.frame_click) = (None, None, None, None);
|
||||
if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] }
|
||||
}
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.claims_letter(key)
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.claims_letter(key) || self.frame_nudge(cx, key, Mods::default()).is_some()
|
||||
}
|
||||
fn notify(&mut self, cx: &ToolContext, what: &str) {
|
||||
if what == FRAME_PICKED {
|
||||
self.frame_revision = cx.revision;
|
||||
}
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
@@ -535,6 +635,7 @@ impl Tool for DirectSelectionTool {
|
||||
self.click = None;
|
||||
self.click_segment = None;
|
||||
self.click_group = None;
|
||||
self.frame_click = None;
|
||||
if let Some(c) = CornerDrag::hit(cx, ev, true) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
@@ -572,7 +673,8 @@ impl Tool for DirectSelectionTool {
|
||||
return self.press_anchor(cx, ev, p, (id, si, ai, grab));
|
||||
}
|
||||
if let Some((id, anchors)) = hit_frame_edge(cx, p, tol) {
|
||||
return self.press_type_area(cx, id, anchors, p, ev.mods.shift);
|
||||
self.frame.clear();
|
||||
return self.press_type_area(cx, id, vec![(id, anchors)], p, ev.mods.shift);
|
||||
}
|
||||
// A curved segment of a selected blend's spine reshapes, as a path's does.
|
||||
if let Some((id, seg, t, false)) = hit_spine_segment(cx, p, tol) {
|
||||
@@ -610,9 +712,9 @@ impl Tool for DirectSelectionTool {
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => self.drag_move_object(cx, ev, p, (start, grab, hold, began)),
|
||||
(PointerKind::Drag, State::TypeArea { id, anchors, start, began }) => {
|
||||
(PointerKind::Drag, State::TypeArea { items, start, began }) => {
|
||||
let mut out = vec![];
|
||||
let on_path = cx.doc.node(id).is_some_and(is_path_type);
|
||||
let on_path = items.first().and_then(|(id, _)| cx.doc.node(*id)).is_some_and(is_path_type);
|
||||
if !began {
|
||||
if p.distance(start) < cx.tol(3.0) {
|
||||
return out;
|
||||
@@ -621,8 +723,8 @@ impl Tool for DirectSelectionTool {
|
||||
}
|
||||
let d = move_delta(start, p, ev.mods.shift, cx.constrain_angle);
|
||||
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.push(Action::Preview(cmd.into(), reshape_params(&items, d)));
|
||||
self.state = State::TypeArea { items, start, began: true };
|
||||
out
|
||||
}
|
||||
(PointerKind::Drag, State::Handle { id, si, ai, out }) => {
|
||||
@@ -702,7 +804,7 @@ impl Tool for DirectSelectionTool {
|
||||
| State::SpinePoint { began, .. }
|
||||
| State::SpineSegment { began, .. }
|
||||
| State::TypeArea { began, .. },
|
||||
) => self.release_move(began),
|
||||
) => self.release_move(cx, began),
|
||||
(PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
@@ -720,7 +822,7 @@ impl Tool for DirectSelectionTool {
|
||||
let mut out = self.spine_overlays(cx);
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx));
|
||||
out.extend(frame_overlays(cx, &self.frame_anchors(cx)));
|
||||
out.extend(pathtype::overlays(cx));
|
||||
}
|
||||
if matches!(self.state, State::Handle { .. } | State::MoveAnchors { .. } | State::MoveObject { .. }) {
|
||||
@@ -833,22 +935,60 @@ fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// path, which Direct Selection drags; the `selected` frame anchors are filled.
|
||||
fn frame_overlays(cx: &ToolContext, selected: &[(NodeId, Vec<AnchorRef>)]) -> 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) || 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 }));
|
||||
let picked = selected.iter().find(|(s, _)| s == id).map(|(_, v)| v.as_slice()).unwrap_or_default();
|
||||
out.extend(frame.anchors().map(|(si, ai, a)| Overlay::Anchor { p: a.p, color, filled: picked.contains(&(si, ai)), size: 5.0 }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl DirectSelectionTool {
|
||||
/// Press on a frame anchor of area type: Shift toggles it among the selected frame anchors;
|
||||
/// without Shift, one not among them becomes the only one, and one among them becomes the only
|
||||
/// one when the press is released without a drag. A drag moves them all.
|
||||
fn press_frame_anchor(&mut self, cx: &ToolContext, id: NodeId, anchor: AnchorRef, p: Point, shift: bool) -> Vec<Action> {
|
||||
let mut picked: BTreeMap<NodeId, BTreeSet<AnchorRef>> =
|
||||
self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect();
|
||||
let on = picked.get(&id).is_some_and(|set| set.contains(&anchor));
|
||||
if shift {
|
||||
let set = picked.entry(id).or_default();
|
||||
if on {
|
||||
set.remove(&anchor);
|
||||
} else {
|
||||
set.insert(anchor);
|
||||
}
|
||||
if set.is_empty() {
|
||||
picked.remove(&id);
|
||||
}
|
||||
} else if !on {
|
||||
picked = BTreeMap::from([(id, BTreeSet::from([anchor]))]);
|
||||
}
|
||||
// The selected objects follow: the type is selected while it has selected frame anchors,
|
||||
// and a click that picks a new anchor without Shift selects that type alone.
|
||||
let mut out = vec![];
|
||||
let any = picked.contains_key(&id);
|
||||
if !shift && !on && cx.selection.objects != [id] {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [id.0]})));
|
||||
} else if shift && cx.selection.contains(id) != any {
|
||||
out.push(Action::Exec("select.toggle".into(), json!({"id": id.0})));
|
||||
}
|
||||
let items: FrameAnchors = picked.iter().map(|(id, set)| (*id, set.iter().copied().collect())).collect();
|
||||
self.state = if items.is_empty() { State::Idle } else { State::TypeArea { items, start: p, began: false } };
|
||||
self.frame = picked;
|
||||
self.frame_click = (on && !shift).then_some((id, anchor));
|
||||
out.push(Action::Notify(FRAME_PICKED.into()));
|
||||
out
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
/// on a path's path, of `id`: select the text and get ready to drag `items`.
|
||||
fn press_type_area(&mut self, cx: &ToolContext, id: NodeId, items: FrameAnchors, p: Point, shift: bool) -> Vec<Action> {
|
||||
self.state = State::TypeArea { items, start: p, began: false };
|
||||
if cx.selection.contains(id) {
|
||||
vec![]
|
||||
} else if shift {
|
||||
@@ -949,8 +1089,11 @@ impl DirectSelectionTool {
|
||||
vec![]
|
||||
}
|
||||
fn press_anchor(&mut self, cx: &ToolContext, ev: &PointerEvent, p: Point, (id, si, ai, grab): (NodeId, usize, usize, Point)) -> Vec<Action> {
|
||||
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);
|
||||
if cx.doc.node(id).is_some_and(is_area_type) {
|
||||
return self.press_frame_anchor(cx, id, (si, ai), p, ev.mods.shift);
|
||||
}
|
||||
if cx.doc.node(id).is_some_and(is_path_type) {
|
||||
return self.press_type_area(cx, id, vec![(id, vec![(si, ai)])], p, ev.mods.shift);
|
||||
}
|
||||
// Alt picks the whole path (and Alt-drag copies it), as on a segment. On a path
|
||||
// already selected whole, an Alt-click adds the next enclosing group on release.
|
||||
@@ -1026,6 +1169,7 @@ impl DirectSelectionTool {
|
||||
if ev.mods.shift {
|
||||
return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))];
|
||||
}
|
||||
self.frame.clear();
|
||||
if !cx.selection.contains(h.leaf) || cx.selection.partial(h.leaf).is_some() {
|
||||
return vec![Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))];
|
||||
}
|
||||
@@ -1119,16 +1263,29 @@ impl DirectSelectionTool {
|
||||
));
|
||||
out
|
||||
}
|
||||
fn release_move(&mut self, began: bool) -> Vec<Action> {
|
||||
fn release_move(&mut self, cx: &ToolContext, began: bool) -> Vec<Action> {
|
||||
let deselect = match self.state {
|
||||
State::MoveObject { deselect, .. } => deselect,
|
||||
_ => None,
|
||||
};
|
||||
// The frame anchors a drag moved stay selected.
|
||||
let frame = matches!(self.state, State::TypeArea { .. }) && !self.frame.is_empty();
|
||||
self.state = State::Idle;
|
||||
self.guides.clear();
|
||||
(self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None);
|
||||
let group = self.click_group.take();
|
||||
// A click on one of several selected frame anchors selects it alone.
|
||||
if let (false, Some((id, anchor))) = (began, self.frame_click.take()) {
|
||||
self.frame = BTreeMap::from([(id, BTreeSet::from([anchor]))]);
|
||||
let mut out = vec![];
|
||||
if cx.selection.objects != [id] {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [id.0]})));
|
||||
}
|
||||
out.push(Action::Notify(FRAME_PICKED.into()));
|
||||
return out;
|
||||
}
|
||||
match (began, deselect, self.click.take(), self.click_segment.take()) {
|
||||
(true, ..) if frame => vec![Action::Commit, Action::Notify(FRAME_PICKED.into())],
|
||||
(true, ..) => vec![Action::Commit],
|
||||
(false, Some(id), _, _) => vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))],
|
||||
(false, None, Some((id, si, ai)), _) => {
|
||||
@@ -1144,7 +1301,11 @@ impl DirectSelectionTool {
|
||||
self.state = State::Idle;
|
||||
let r = Rect::from_points(start, p);
|
||||
if r.width() < cx.tol(3.0) && r.height() < cx.tol(3.0) {
|
||||
return if toggle { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
|
||||
if toggle {
|
||||
return vec![];
|
||||
}
|
||||
self.frame.clear();
|
||||
return vec![Action::Exec("select.none".into(), json!({}))];
|
||||
}
|
||||
// With Group Selection's enclosed marquee, the tool selects the objects inside it
|
||||
// (members of groups too), each whole, as a click selects one.
|
||||
@@ -1166,18 +1327,54 @@ impl DirectSelectionTool {
|
||||
}
|
||||
}
|
||||
});
|
||||
let editable = cx.doc.editable();
|
||||
// Direct Selection: the frame anchors of area type inside the rect are selected
|
||||
// (with Shift, toggled), and the type with them, whole.
|
||||
let mut framed: Vec<(NodeId, BTreeSet<AnchorRef>)> = vec![];
|
||||
if !self.group {
|
||||
walk_pickable(cx.doc, cx.isolation, |n| {
|
||||
if is_area_type(n)
|
||||
&& let Some(frame) = editable_path(n)
|
||||
{
|
||||
let inside: BTreeSet<AnchorRef> = frame.anchors().filter(|(_, _, a)| r.contains(a.p)).map(|(s, i, _)| (s, i)).collect();
|
||||
if !inside.is_empty() {
|
||||
framed.push((n.id, inside));
|
||||
}
|
||||
}
|
||||
});
|
||||
framed.retain(|(id, _)| editable(*id));
|
||||
}
|
||||
let mut marked: BTreeMap<NodeId, BTreeSet<AnchorRef>> =
|
||||
if toggle { self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect() } else { BTreeMap::new() };
|
||||
for (id, inside) in &framed {
|
||||
let set = marked.entry(*id).or_default();
|
||||
*set = if toggle { set.symmetric_difference(inside).copied().collect() } else { inside.clone() };
|
||||
let any = !set.is_empty();
|
||||
if !any {
|
||||
marked.remove(id);
|
||||
}
|
||||
// With Shift the type is toggled only when it gains its first selected frame
|
||||
// anchor or loses its last one.
|
||||
if !toggle || cx.selection.contains(*id) != any {
|
||||
sel.push((*id, vec![]));
|
||||
}
|
||||
}
|
||||
// Objects without anchors to pick (type, images, symbols…) are selected whole when
|
||||
// the marquee touches them, as with the Selection tool (#927).
|
||||
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
|
||||
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) {
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) {
|
||||
sel.push((id, vec![]));
|
||||
}
|
||||
}
|
||||
let editable = cx.doc.editable();
|
||||
sel.retain(|(id, _)| editable(*id));
|
||||
self.frame = marked;
|
||||
let items: Vec<Value> = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect();
|
||||
let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }}));
|
||||
std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect()
|
||||
let mut out: Vec<Action> = std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect();
|
||||
if !self.frame.is_empty() {
|
||||
out.push(Action::Notify(FRAME_PICKED.into()));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,13 +284,13 @@ fn dragging_a_frame_corner_or_edge_reshapes_area_type() {
|
||||
// The bottom-right corner of the 120 × 40 frame at (300, 300) selects the text...
|
||||
assert_eq!(
|
||||
t.pointer(&cx1, &PointerEvent::new(PointerKind::Down, 421.0, 339.0)),
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]}))]
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [text.0]})), Action::Notify(FRAME_PICKED.into())]
|
||||
);
|
||||
// ...and drags that corner.
|
||||
let a = t.pointer(&cx1, &PointerEvent::new(PointerKind::Drag, 461.0, 399.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Type Area".into()));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 40.0, "dy": 60.0})));
|
||||
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit]);
|
||||
assert_eq!(t.pointer(&cx1, &PointerEvent::new(PointerKind::Up, 461.0, 399.0)), vec![Action::Commit, Action::Notify(FRAME_PICKED.into())]);
|
||||
// The top edge drags both its ends; a click alone changes nothing.
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
@@ -299,10 +299,146 @@ fn dragging_a_frame_corner_or_edge_reshapes_area_type() {
|
||||
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Drag, 350.0, 281.0));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 0], [0, 1]], "dx": 0.0, "dy": -20.0})));
|
||||
t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 350.0, 281.0));
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)).is_empty());
|
||||
assert_eq!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 300.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
assert!(t.pointer(&cx2, &PointerEvent::new(PointerKind::Up, 300.0, 340.0)).is_empty());
|
||||
// The selected frame's corners show.
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 4);
|
||||
// The selected frame's corners show, the one clicked filled.
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { filled: false, .. })).count(), 3);
|
||||
assert_eq!(t.overlays(&cx2).iter().filter(|o| matches!(o, Overlay::Anchor { p, filled: true, .. } if *p == Point::new(300.0, 340.0))).count(), 1);
|
||||
}
|
||||
|
||||
/// The frame anchor squares of the selected area type that `t` draws: (filled, hollow).
|
||||
fn frame_squares(t: &DirectSelectionTool, c: &ToolContext) -> (Vec<Point>, Vec<Point>) {
|
||||
let mut out = (vec![], vec![]);
|
||||
for o in t.overlays(c) {
|
||||
if let Overlay::Anchor { p, filled, size: 5.0, .. } = o {
|
||||
if filled { out.0.push(p) } else { out.1.push(p) }
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Direct Selection marquee selects the frame anchors of area type inside it, which show
|
||||
/// filled: a drag of one moves them all, the arrow keys nudge them, and Shift toggles them.
|
||||
#[test]
|
||||
fn a_marquee_selects_frame_anchors_of_area_type() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let cx1 = cx(&d, &none, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let ev = |k, x, y| PointerEvent::new(k, x, y);
|
||||
let shift = |k, x, y| PointerEvent::new(k, x, y).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
// A marquee round the 120 × 40 frame's right-hand corners, (420, 300) and (420, 340).
|
||||
assert!(t.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0)).is_empty());
|
||||
t.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
assert_eq!(
|
||||
t.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0)),
|
||||
vec![
|
||||
Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": text.0, "anchors": []}], "mode": "set"})),
|
||||
Action::Notify(FRAME_PICKED.into()),
|
||||
]
|
||||
);
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
t.notify(&cx2, FRAME_PICKED);
|
||||
let (filled, hollow) = frame_squares(&t, &cx2);
|
||||
assert_eq!(filled, vec![Point::new(420.0, 300.0), Point::new(420.0, 340.0)]);
|
||||
assert_eq!(hollow, vec![Point::new(300.0, 300.0), Point::new(300.0, 340.0)]);
|
||||
// A drag of one of them moves both.
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
let a = t.pointer(&cx2, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
assert_eq!(a[0], Action::Begin("Reshape Type Area".into()));
|
||||
assert_eq!(a[1], Action::Preview("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 1], [0, 2]], "dx": 20.0, "dy": 20.0})));
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Up, 440.0, 360.0)), vec![Action::Commit, Action::Notify(FRAME_PICKED.into())]);
|
||||
// The arrow keys nudge them by the keyboard increment, ten times that with Shift.
|
||||
let nudged = |anchors: Value, dx: f64, dy: f64| {
|
||||
vec![
|
||||
Action::Exec("text.reshapeArea".into(), json!({"id": text.0, "anchors": anchors, "dx": dx, "dy": dy})),
|
||||
Action::Notify(FRAME_PICKED.into()),
|
||||
]
|
||||
};
|
||||
assert!(t.claims_key(&cx2, ToolKey::Right) && !t.claims_key(&cx2, ToolKey::Enter));
|
||||
assert_eq!(t.key(&cx2, ToolKey::Right, Mods::default()), nudged(json!([[0, 1], [0, 2]]), 1.0, 0.0));
|
||||
assert_eq!(t.key(&cx2, ToolKey::Up, Mods { shift: true, ..Mods::default() }), nudged(json!([[0, 1], [0, 2]]), 0.0, -10.0));
|
||||
// Any other change of the document or the selection (Deselect, then the same type selected
|
||||
// again) leaves the type selected whole: the arrow keys are no longer the tool's.
|
||||
let mut later = cx(&d, &s, &p);
|
||||
later.revision = (0, 1);
|
||||
assert!(frame_squares(&t, &later).0.is_empty());
|
||||
assert!(!t.claims_key(&later, ToolKey::Right));
|
||||
// Shift-click takes one out; the other still nudges.
|
||||
assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 420.0, 300.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
assert!(t.pointer(&cx2, &shift(PointerKind::Up, 420.0, 300.0)).is_empty());
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 340.0)]);
|
||||
assert_eq!(t.key(&cx2, ToolKey::Down, Mods::default()), nudged(json!([[0, 2]]), 0.0, 1.0));
|
||||
// A Shift-marquee round the bottom corners toggles them: (300, 340) joins, (420, 340) leaves.
|
||||
t.pointer(&cx2, &shift(PointerKind::Down, 280.0, 330.0));
|
||||
t.pointer(&cx2, &shift(PointerKind::Drag, 440.0, 360.0));
|
||||
let a = t.pointer(&cx2, &shift(PointerKind::Up, 440.0, 360.0));
|
||||
assert_eq!(a[0], Action::Exec("select.anchorsMany".into(), json!({"items": [], "mode": "toggle"})), "the type stays selected");
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(300.0, 340.0)]);
|
||||
// When the selection changes, the frame anchors are no longer selected.
|
||||
assert!(frame_squares(&t, &cx1).0.is_empty());
|
||||
assert!(!t.claims_key(&cx1, ToolKey::Right) && t.key(&cx1, ToolKey::Right, Mods::default()).is_empty());
|
||||
// A marquee that also reaches a path's anchor (the rectangle's corner at (200, 200)) selects
|
||||
// the type whole, as before: the arrow keys move the path's anchor and the type alike.
|
||||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
t.pointer(&cx1, &ev(PointerKind::Down, 440.0, 360.0));
|
||||
t.pointer(&cx1, &ev(PointerKind::Drag, 190.0, 190.0));
|
||||
let a = t.pointer(&cx1, &ev(PointerKind::Up, 190.0, 190.0));
|
||||
let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]);
|
||||
assert_eq!(a[0], Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"})));
|
||||
let mut mixed = Selection::default();
|
||||
mixed.add(rect);
|
||||
mixed.anchors.insert(rect, [(0, 2)].into());
|
||||
mixed.add(text);
|
||||
let cx3 = cx(&d, &mixed, &p);
|
||||
t.notify(&cx3, FRAME_PICKED);
|
||||
assert!(frame_squares(&t, &cx3).0.is_empty());
|
||||
assert!(!t.claims_key(&cx3, ToolKey::Right));
|
||||
// Group Selection's marquee selects the type whole, without its frame anchors.
|
||||
let mut g = DirectSelectionTool::new(true);
|
||||
g.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0));
|
||||
g.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
assert_eq!(
|
||||
g.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0)),
|
||||
vec![Action::Exec("select.anchorsMany".into(), json!({"items": [{"id": text.0, "anchors": []}], "mode": "set"}))]
|
||||
);
|
||||
}
|
||||
|
||||
/// A click without a drag on one of several selected frame anchors selects it alone, as a click on
|
||||
/// a selected path anchor does; a drag of it still moves them all.
|
||||
#[test]
|
||||
fn a_click_on_a_selected_frame_anchor_selects_it_alone() {
|
||||
let (d, text) = doc_with_area_type();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let cx1 = cx(&d, &none, &p);
|
||||
let mut t = DirectSelectionTool::new(false);
|
||||
let ev = |k, x, y| PointerEvent::new(k, x, y);
|
||||
// A marquee round the right-hand corners, (420, 300) and (420, 340).
|
||||
t.pointer(&cx1, &ev(PointerKind::Down, 400.0, 280.0));
|
||||
t.pointer(&cx1, &ev(PointerKind::Drag, 440.0, 360.0));
|
||||
t.pointer(&cx1, &ev(PointerKind::Up, 440.0, 360.0));
|
||||
let mut s = Selection::default();
|
||||
s.add(text);
|
||||
let cx2 = cx(&d, &s, &p);
|
||||
t.notify(&cx2, FRAME_PICKED);
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 300.0), Point::new(420.0, 340.0)]);
|
||||
// A click on (420, 340) keeps both until the release, then selects it alone.
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Down, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
assert_eq!(frame_squares(&t, &cx2).0.len(), 2, "both, for a drag");
|
||||
assert_eq!(t.pointer(&cx2, &ev(PointerKind::Up, 420.0, 340.0)), vec![Action::Notify(FRAME_PICKED.into())]);
|
||||
t.notify(&cx2, FRAME_PICKED);
|
||||
assert_eq!(frame_squares(&t, &cx2).0, vec![Point::new(420.0, 340.0)]);
|
||||
assert_eq!(
|
||||
t.key(&cx2, ToolKey::Right, Mods::default()),
|
||||
vec![
|
||||
Action::Exec("text.reshapeArea".into(), json!({"id": text.0, "anchors": [[0, 2]], "dx": 1.0, "dy": 0.0})),
|
||||
Action::Notify(FRAME_PICKED.into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// #3980: type on a path shows its path's anchors and a drag of one reshapes the path, the last
|
||||
@@ -710,7 +846,8 @@ fn marquee_selects_type_it_touches() {
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0, 150.0));
|
||||
let items = json!([{"id": rect.0, "anchors": [[0, 2]]}, {"id": text.0, "anchors": []}]);
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"}))]);
|
||||
// The frame's corners are inside too: they are selected with the type.
|
||||
assert_eq!(a, vec![Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": "set"})), Action::Notify(FRAME_PICKED.into())]);
|
||||
// Away from the type, only anchors.
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 50.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
|
||||
|
||||
+3
-1
@@ -4388,7 +4388,9 @@ As in the reference app, dragging a bounding-box handle of area type resizes its
|
||||
type: the Selection tool sends `object.transform {matrix, typeAreas: true}`, which reshapes the frame of each area
|
||||
type object it transforms (other objects, type inside groups and point type transform as usual) and reflows the
|
||||
text at its size, through its thread too. `text.reshapeArea {id, anchors: [[subpath, anchor]…], dx, dy}` moves
|
||||
frame anchors with their handles (Direct Selection dragging a corner, or an edge's two ends), and
|
||||
frame anchors with their handles (Direct Selection dragging a corner, an edge's two ends or the frame anchors its
|
||||
marquee selected, and the arrow keys nudging those); `{items: [{id, anchors}…], dx, dy}` moves the anchors of several
|
||||
frames in one step, 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
|
||||
|
||||
@@ -688,6 +688,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
`path.reshapeSegment` refuses a delta of 1e9 pt or longer, as `object.blend.spine.reshapeSegment` does, and `path.convertAnchor` and `path.freehand` refuse a point whose x or y is 1e9 pt or more in size.
|
||||
`path.reshapeSegment`, `object.blend.spine.reshapeSegment`, `path.convertAnchor`, `path.curvatureEdit`, `path.insertAnchor`, `path.moveAnchors`, `path.reshape`, `path.close` and the Pencil, Curvature, Knife, Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a number, point or handle given as text, such as "10", and leave the document unchanged; `path.convertAnchor` refuses an `x` without a `y`, or a `y` without an `x`.
|
||||
`path.close` and `path.setHandle` (`independent`), `path.moveAnchors` (`patterns`), the Pencil (`closed`, `keepSelected`, `fill`), Curvature (`closed` and a point's `corner`), `path.curvatureEdit` (`corner`), `path.reshapeSegment` (`perpendicular`) and the Blob Brush (`keepSelected`, `mergeOnlySelection`, `merge`) refuse a value other than true, false or null, such as "true", and leave the document unchanged.
|
||||
Direct Selection's marquee selects the frame anchors of area type that lie inside it, and a click on a frame anchor selects that one; Shift toggles them. The selected frame anchors show filled. A drag of one of them moves all of them, the arrow keys nudge them, and the type reflows at its size (`text.reshapeArea`, which takes `items` to move the anchors of several frames in one step), as [Create text](http://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) and [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describe. When the selection also holds other objects, path anchors or ruler guides, or when another change to the document or the selection follows the pick, the type is selected whole.
|
||||
- **Group Selection (#2441):** a click picks one path of a compound path (its outline within the pick tolerance, else the innermost path whose area holds the click: the outer shape or a hole), Shift-click adds another, and a click on a selected path adds the compound path, as a click on a group member climbs to its group. A path selected this way moves, rotates and scales on its own; paint still goes to the compound path.
|
||||
With Direct Selection, an Alt-click on a path selected whole adds its group, and each further Alt-click adds the next group up, as Group Selection's clicks do; the climb stops below the layer and its sublayers and at the isolated object. An Alt-drag still copies the selection, and Alt+Shift-click on a segment or the fill still adds or removes the path. Public references: [Select objects](https://web.archive.org/web/20250904012025/https://helpx.adobe.com/illustrator/using/selecting-objects.html) and [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) (archived copies).
|
||||
With Direct Selection, Shift pressed during the drag of a curved segment keeps both of its handles at right angles to the line between its anchors, on the same side and at the same length (`path.reshapeSegment {perpendicular}`). Shift held while any segment is dragged with the Anchor Point tool or the Pen with Alt does the same. With the Anchor Point tool, Shift held at the press on an anchor (or a selected blend's spine point) moves it, handles and all, instead of pulling out handles; Shift kept down keeps the move at 45° steps, and a Shift-click that doesn't move leaves the anchor as it was (`path.moveAnchor`). A selected blend spine's segment dragged with the Anchor Point tool, or a curved one dragged with Direct Selection, bends the same way (`object.blend.spine.reshapeSegment`).
|
||||
|
||||
Reference in New Issue
Block a user