mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 20:12:49 +00:00
Merge #283: fix(engine): Alt-drag copies snap to their original; Cmd+D repeats the step (#256) (@Nael-Nathanael)
This commit is contained in:
commit
8c8c00fcba
8 files changed
+253
-22
No files matched your search
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
@@ -412,13 +412,48 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"deleted": ids}))
|
||||
}
|
||||
|
||||
/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the
|
||||
/// copies are the selection, Duplicate places the next copies as far from them as they are from
|
||||
/// their sources, measured then: so moving the copies first sets the step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Duplicated {
|
||||
pub sources: Vec<NodeId>,
|
||||
pub copies: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Duplicated {
|
||||
pub fn is_selection(&self, sel: &[NodeId]) -> bool {
|
||||
let set = |v: &[NodeId]| v.iter().copied().collect::<BTreeSet<_>>();
|
||||
!self.copies.is_empty() && set(&self.copies) == set(sel)
|
||||
}
|
||||
|
||||
/// The page offset from the sources to the copies, if both are still there and the copies move
|
||||
/// freely (an auto layout places its own).
|
||||
fn offset(&self, doc: &Document) -> Option<(f64, f64)> {
|
||||
let free = self
|
||||
.copies
|
||||
.iter()
|
||||
.all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none())));
|
||||
if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?);
|
||||
let d = (b.x0 - a.x0, b.y0 - a.y0);
|
||||
(d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d)
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.duplicate", s, p)?;
|
||||
let rename = s.prefs.rename_duplicates;
|
||||
let explicit = num(p, "dx").is_some() || num(p, "dy").is_some();
|
||||
let ds = s.ds()?;
|
||||
let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids));
|
||||
let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc));
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let ids = roots_now;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
for i in ids.iter().copied() {
|
||||
let n = doc.get(i)?;
|
||||
let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?;
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
@@ -432,14 +467,21 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ if top_frame => (w + 100.0, 0.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
match repeat {
|
||||
Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?,
|
||||
None => {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Duplicating at a spacing: a copy Alt-dragged off a layer snaps to that layer too (its edges,
|
||||
//! centres and equal spacing), and Duplicate repeats the last duplicate's offset while its copies
|
||||
//! are selected, so Alt-drag (or Duplicate and move) then Cmd+D steps out evenly spaced copies.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
|
||||
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100}))["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// Alt-drag from `from` to `to`: returns the copy (the selection afterwards).
|
||||
fn alt_drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent { alt: true, ..PointerEvent::at(kind, x, y, 1.0) };
|
||||
s.pointer(&ev(PointerKind::Down, from)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, to)).unwrap();
|
||||
let sel = s.selection();
|
||||
assert_eq!(sel.len(), 1, "one copy selected");
|
||||
sel[0]
|
||||
}
|
||||
|
||||
/// Duplicate the selection: the copy.
|
||||
fn cmd_d(s: &mut Session) -> NodeId {
|
||||
let out = ex(s, "edit.duplicate", json!({}));
|
||||
id_of(&out["ids"][0]).unwrap()
|
||||
}
|
||||
|
||||
fn at(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_dragged_copy_snaps_to_the_layer_it_came_from() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
// 2 px below the original's top edge: within the snap distance.
|
||||
let copy = alt_drag(&mut s, (50.0, 50.0), (250.0, 52.0));
|
||||
assert_ne!(copy, a);
|
||||
assert_eq!(at(&s, copy), (200.0, 0.0), "the copy's top lines up with the original's");
|
||||
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_dragged_copy_spaces_equally_with_the_layer_it_came_from() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0);
|
||||
rect(&mut s, 200.0, 0.0);
|
||||
// B's copy dropped 2 px short of the 100 gap that A and B have.
|
||||
let copy = alt_drag(&mut s, (250.0, 50.0), (448.0, 50.0));
|
||||
assert_eq!(at(&s, copy), (400.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_repeats_the_step_set_by_moving_the_copy() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150, "y": 20}}));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
|
||||
let c3 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_after_an_alt_drag_repeats_its_step() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0);
|
||||
let copy = alt_drag(&mut s, (50.0, 50.0), (180.0, 50.0));
|
||||
assert_eq!(at(&s, copy), (130.0, 0.0));
|
||||
let next = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, next), (260.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_only_repeats_for_the_last_copies() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
// Another selection forgets it: the original duplicates in place.
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
ex(&mut s, "select.set", json!({"ids": [c1]}));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (150.0, 0.0), "the step was forgotten when the selection changed");
|
||||
// An offset given explicitly wins over a remembered one.
|
||||
ex(&mut s, "node.set", json!({"ids": [c2], "props": {"x": 300}}));
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"dx": 10}));
|
||||
let c3 = id_of(&out["ids"][0]).unwrap();
|
||||
assert_eq!(at(&s, c3), (310.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_doesnt_let_a_new_layer_take_a_remembered_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc().unwrap().contains(c1));
|
||||
// Undo gives ids back: the new rectangle may well get the undone copy's.
|
||||
let r = rect(&mut s, 500.0, 500.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let c = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c), (500.0, 500.0), "a plain duplicate, in place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_step_is_measured_in_page_space_inside_a_rotated_frame() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 600, "height": 600}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"rotation": 30}}));
|
||||
let a = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": f}))["id"]).unwrap();
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
let (p0, p1) = (at(&s, a), at(&s, c1));
|
||||
let c2 = cmd_d(&mut s);
|
||||
let p2 = at(&s, c2);
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
assert!(close(p2.0 - p1.0, p1.0 - p0.0) && close(p2.1 - p1.1, p1.1 - p0.1), "{p0:?} {p1:?} {p2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_drag_into_auto_layout_sets_no_step() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
|
||||
for i in 0..2 {
|
||||
ex(&mut s, "shape.rectangle", json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f}));
|
||||
}
|
||||
ex(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
let kids = s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
let b = s.doc().unwrap().world_bounds(kids[0]).unwrap();
|
||||
let from = (b.center().x, b.center().y);
|
||||
alt_drag(&mut s, from, (from.0 + 70.0, from.1));
|
||||
assert!(s.ds().unwrap().last_duplicate.is_none(), "the layout placed the copy: no step to repeat");
|
||||
let n = s.doc().unwrap().node(f).unwrap().children.len();
|
||||
cmd_d(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
|
||||
}
|
||||
@@ -328,6 +328,9 @@ pub struct DocState {
|
||||
pub guide: Option<(NodeId, usize)>,
|
||||
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
|
||||
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
|
||||
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
|
||||
/// [`cmd::edit::Duplicated`]; not saved).
|
||||
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
|
||||
}
|
||||
|
||||
/// A version shown on the canvas in place of the document: read-only until it's restored or the
|
||||
@@ -383,6 +386,7 @@ impl DocState {
|
||||
focus: None,
|
||||
guide: None,
|
||||
rotation_origin: None,
|
||||
last_duplicate: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
st
|
||||
@@ -538,6 +542,10 @@ impl DocState {
|
||||
if self.rotation_origin.as_ref().is_some_and(|o| o.ids != self.selection) {
|
||||
self.rotation_origin = None;
|
||||
}
|
||||
// So does the last duplicate (undo reuses ids: a new layer could take a copy's).
|
||||
if self.last_duplicate.as_ref().is_some_and(|d| !d.is_selection(&self.selection)) {
|
||||
self.last_duplicate = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1441,6 +1449,8 @@ mod double_click_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod duplicate_spacing_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod export_behaviour_tests;
|
||||
|
||||
@@ -196,16 +196,17 @@ fn alt_drag_shows_live_distances_to_the_neighbours() {
|
||||
e
|
||||
};
|
||||
s.pointer(&ev(PointerKind::Down, 30.0)).unwrap();
|
||||
// Alt duplicates: the copy moves to 110..160, between the original and the other layer.
|
||||
// Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original
|
||||
// and the other layer (105..155).
|
||||
s.pointer(&ev(PointerKind::Move, 130.0)).unwrap();
|
||||
let g = s.tool.overlay.guides.clone();
|
||||
assert_eq!(
|
||||
labelled(&g),
|
||||
vec![
|
||||
("30".into(), [135, 0, 135, 30]),
|
||||
("30".into(), [135, 70, 135, 100]),
|
||||
("40".into(), [160, 50, 200, 50]),
|
||||
("50".into(), [60, 50, 110, 50])
|
||||
("30".into(), [130, 0, 130, 30]),
|
||||
("30".into(), [130, 70, 130, 100]),
|
||||
("45".into(), [60, 50, 105, 50]),
|
||||
("45".into(), [155, 50, 200, 50])
|
||||
]
|
||||
);
|
||||
s.pointer(&ev(PointerKind::Up, 130.0)).unwrap();
|
||||
|
||||
+12
-10
@@ -74,15 +74,6 @@ impl Targets {
|
||||
// A layer's box in snap space.
|
||||
let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite());
|
||||
let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() };
|
||||
let push = |r: Rect, t: &mut Targets| {
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
t.xs.push((x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
t.ys.push((y, r.x0, r.x1));
|
||||
}
|
||||
t.boxes.push(r);
|
||||
};
|
||||
let parent = parent_of(doc, page, moving);
|
||||
// The parent frame's own box (and its layout grids).
|
||||
if parent != page
|
||||
@@ -109,7 +100,7 @@ impl Targets {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = bbox(*c) {
|
||||
push(b, &mut t);
|
||||
t.add_box(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +127,17 @@ impl Targets {
|
||||
t
|
||||
}
|
||||
|
||||
/// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it.
|
||||
pub fn add_box(&mut self, r: Rect) {
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
self.xs.push((x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
self.ys.push((y, r.x0, r.x1));
|
||||
}
|
||||
self.boxes.push(r);
|
||||
}
|
||||
|
||||
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
|
||||
/// and a marker when a moving point lands on it.
|
||||
pub fn add_point(&mut self, p: Point) {
|
||||
|
||||
@@ -1196,7 +1196,13 @@ impl Session {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let targets = Targets::gather(&doc, page, &ids);
|
||||
let mut targets = Targets::gather(&doc, page, &ids);
|
||||
// A copy dragged out with Alt snaps to the layers it came from too (they stay put).
|
||||
if ev.alt
|
||||
&& let Some(b) = targets.moving
|
||||
{
|
||||
targets.add_box(b);
|
||||
}
|
||||
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
|
||||
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
|
||||
@@ -1707,6 +1713,7 @@ impl Session {
|
||||
// dropped; layers in an auto layout frame already sit in their slot.
|
||||
let page = self.page()?;
|
||||
let sel = self.selection();
|
||||
let sources = if duplicate { ids.clone() } else { Vec::new() };
|
||||
let moving = if duplicate { sel } else { ids };
|
||||
let doc = self.doc()?.clone();
|
||||
let in_al =
|
||||
@@ -1735,6 +1742,10 @@ impl Session {
|
||||
self.cancel_interaction()?;
|
||||
} else {
|
||||
self.commit_interaction()?;
|
||||
// Duplicate repeats a free Alt-drag's step (an auto layout places its own).
|
||||
if duplicate && !in_al && moving.len() == sources.len() {
|
||||
self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
|
||||
@@ -1406,6 +1406,12 @@ turned off 0°.
|
||||
With `pointer`, `alt: true` hovers measure (add `cmd` for nested layers, `shift` for padding) and
|
||||
`alt` drags show live distances to the neighbours; `inspect_ui` reports them under
|
||||
`tool.overlay` (`guides`, and `measure_box` for the box measured to).
|
||||
- **Duplicating at a spacing**: a copy Alt-dragged off a layer with `pointer` snaps to that layer
|
||||
too (its edges, centres and equal spacing). `edit.duplicate {ids?, dx?, dy?}` without `dx`/`dy`
|
||||
repeats the last duplicate's page offset while its copies are what it duplicates (and still the
|
||||
selection), measured when it runs: Alt-drag or duplicate and move a copy, then each
|
||||
`edit.duplicate` steps out the next one as far again. Copies placed by an auto layout set no
|
||||
step; explicit `dx`/`dy` always win.
|
||||
- **Scale tool** (`tool.scale`, K): dragging a selection handle with `pointer` scales everything by
|
||||
one factor (sizes and positions whatever the constraints, strokes, effects, corner radii, auto
|
||||
layout spacing, min/max sizes, layout grids, guides, tiled images, text), one undo step per drag,
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ interaction feel not yet compared.
|
||||
| Flip, rotate 90° | done | |
|
||||
| Rotation origin (Alt+R) | done | a handle at the selection's centre to drag (snaps to the box's corners, edge middles and centre), double-click puts it back, Escape or Alt+R hides it; the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about it; it follows the layers and lasts as long as the selection, not saved (the documentation doesn't say; pinned) |
|
||||
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. No crosshair to move with the arrows first |
|
||||
| Alt-drag duplicate | done | |
|
||||
| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) |
|
||||
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
|
||||
| Scale tool (K): handles about an anchor, live factor, panel | done | |
|
||||
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
|
||||
|
||||
Reference in new issue
Block a user