mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
fix(engine): Alt-drag copies snap to their original; Duplicate repeats the step
- A copy Alt-dragged off a layer snaps to that layer too: its edges, centres and equal spacing (Targets::add_box with the originals' box). Before, the originals were left out of the snap targets, so a copy couldn't line up with or space evenly from the layer it came from. - Duplicate (Cmd+D) repeats the last duplicate's page offset while its copies are what it duplicates: Alt-drag a copy, or Duplicate and move the copy, then each Cmd+D steps out the next one as far again. The step is measured when Duplicate runs. It lasts as long as the selection (undo reuses ids), copies placed by an auto layout set no step, and explicit dx/dy win. - measure_tests: an Alt-dragged copy between its original and another layer now snaps to equal spacing (45/45 instead of 50/40). Tests: duplicate_spacing_tests (snap to the original, equal spacing, repeat after moving a copy and after an Alt-drag, only for the last copies, undo then a new layer, rotated frame, auto layout). Refs #256 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bcedfeba10
commit
73b2845ba4
@@ -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) {
|
||||
|
||||
@@ -1164,7 +1164,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 };
|
||||
@@ -1675,6 +1681,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 =
|
||||
@@ -1703,6 +1710,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