Merge main into #2310

This commit is contained in:
bflatastic committed 2026-11-14 00:40:47 +07:00
commit 719044412f
10 files changed
+212 -34

No files matched your search

+39 -16
View File
@@ -152,7 +152,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool {
/// boolean groups are hit where their `result` is.
pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
let p = doc.node(page)?;
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt)))
p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None, HitOpts::default()).filter(|_| !masked_out(doc, p, *c, pt)))
}
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
@@ -175,7 +175,7 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool
/// Like [`hit_leaf`], but only within layer `root` (it included).
pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option<NodeId> {
hit_rec(doc, root, pt, tol, 0, result, None)
hit_rec(doc, root, pt, tol, 0, result, None, HitOpts::default())
}
/// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit
@@ -185,8 +185,8 @@ pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolRes
/// when it shows them, and every layer on the edge of its box when it shows object bounds.
pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option<NodeId> {
match doc.node(root) {
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))),
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also)),
Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also), HitOpts::default())),
_ => hit_rec(doc, root, pt, tol, 0, result, Some(also), HitOpts::default()),
}
}
@@ -204,13 +204,30 @@ fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> boo
r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp))
}
fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option<OutlineHits>) -> Option<NodeId> {
/// Options for the hit tests that the plain pick never sets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct HitOpts {
/// Reach locked layers (and what a locked frame holds) too. A click, hover or marquee never
/// does; the Select layer list does.
pub locked: bool,
}
fn hit_rec(
doc: &Document,
id: NodeId,
pt: Point,
tol: f64,
depth: usize,
result: BoolResult,
also: Option<OutlineHits>,
opts: HitOpts,
) -> Option<NodeId> {
if depth > 512 {
return None;
}
let n = doc.node(id)?;
let outline = also.is_some();
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked {
if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked && !opts.locked {
return None;
}
if let NodeKind::Section { contents_hidden: true } = n.kind {
@@ -228,7 +245,7 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
return None;
}
for c in n.paint_order().rev() {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) {
if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also, opts).filter(|_| !masked_out(doc, n, c, pt)) {
return Some(h);
}
}
@@ -239,14 +256,20 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result
None
}
/// Every visible, unlocked layer under `pt` (page coordinates) with its depth below the page, in
/// layer-tree order (each layer before what it contains, the front-most first): what
/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups (hit where
/// their `result` is) and sections with hidden contents count as one layer.
/// Every visible, unlocked layer under `pt`: [`layers_at_opts`] with the default options.
pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Vec<(NodeId, usize)> {
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, out: &mut Vec<(NodeId, usize)>) {
layers_at_opts(doc, page, pt, tol, result, HitOpts::default())
}
/// Every visible layer under `pt` (page coordinates) with its depth below the page, in
/// layer-tree order (each layer before what it contains, the front-most first): what
/// "Select layer" in the context menu lists. Locked layers are left out unless `opts.locked`.
/// Clip content is honoured; boolean groups (hit where their `result` is) and sections with
/// hidden contents count as one layer.
pub fn layers_at_opts(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult, opts: HitOpts) -> Vec<(NodeId, usize)> {
fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, opts: HitOpts, out: &mut Vec<(NodeId, usize)>) {
let Some(n) = doc.node(id) else { return };
if !n.visible || n.locked || depth > 512 {
if !n.visible || n.locked && !opts.locked || depth > 512 {
return;
}
let clips = n.clip && n.kind.is_frame_like();
@@ -256,14 +279,14 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
let at = out.len();
let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true });
if whole {
if hit_rec(doc, id, pt, tol, depth, result, None).is_some() {
if hit_rec(doc, id, pt, tol, depth, result, None, opts).is_some() {
out.push((id, depth));
}
return;
}
for c in n.paint_order().rev() {
let before = out.len();
rec(doc, c, pt, tol, depth + 1, result, out);
rec(doc, c, pt, tol, depth + 1, result, opts, out);
if out.len() > before && masked_out(doc, n, c, pt) {
out.truncate(before);
}
@@ -276,7 +299,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool
if let Some(p) = doc.node(page) {
for c in p.children.iter().rev() {
let before = out.len();
rec(doc, *c, pt, tol, 0, result, &mut out);
rec(doc, *c, pt, tol, 0, result, opts, &mut out);
if out.len() > before && masked_out(doc, p, *c, pt) {
out.truncate(before);
}
+1 -1
View File
@@ -207,7 +207,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"select.layersAt",
"Layers at a point",
"{x, y (page coordinates)} → {layers: [{id, name, type, depth}]} (every layer under the point, front-most first; what the context menu's Select layer lists)",
"{x, y (page coordinates)} → {layers: [{id, name, type, depth, locked}]} (every layer under the point, locked ones too, front-most first; locked is true when the layer or an ancestor is locked; what the context menu's Select layer lists)",
false,
layers_at
),
+6 -2
View File
@@ -8,9 +8,13 @@ pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result<Value> {
let zoom = s.ds()?.view.zoom.max(1e-6);
let sel = s.selection();
let result = crate::booleans::result_outline(doc, &sel);
let layers: Vec<Value> = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result))
let opts = figcraft_doc::geometry::HitOpts { locked: true };
let layers: Vec<Value> = figcraft_doc::geometry::layers_at_opts(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result), opts)
.into_iter()
.filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth})))
.filter_map(|(id, depth)| {
doc.node(id)
.map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth, "locked": crate::tools::locked_in(doc, id)}))
})
.collect();
Ok(json!({"layers": layers}))
}
@@ -118,8 +118,33 @@ fn layers_at_a_point_list_the_stack_front_most_first() {
let got: Vec<(NodeId, u64)> =
out["layers"].as_array().unwrap().iter().map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap())).collect();
assert_eq!(got, vec![(f, 0), (over, 1), (g, 1), (a, 2)]);
assert!(out["layers"].as_array().unwrap().iter().all(|l| l["locked"] == json!(false)), "none is locked");
assert_eq!(out["layers"][0]["type"], json!("FRAME"));
// Nothing under empty canvas; junk is refused.
assert_eq!(ex(&mut s, "select.layersAt", json!({"x": 2000, "y": 2000}))["layers"], json!([]));
assert!(s.execute("select.layersAt", &json!({"x": "left"})).is_err());
}
/// Measured in Figma's web app, 2026-11-10 (Select layer lists a locked layer, and picking it selects it).
#[test]
fn layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them() {
let mut s = Session::new();
let (f, g, [a, ..], _) = scene(&mut s);
let over = rect_in(&mut s, 10.0, 10.0, Some(f));
let lk = rect_in(&mut s, 10.0, 10.0, Some(f));
ex(&mut s, "object.toggleLock", json!({"ids": [lk], "locked": true}));
let out = ex(&mut s, "select.layersAt", json!({"x": 30, "y": 30}));
let got: Vec<(NodeId, u64, bool)> = out["layers"]
.as_array()
.unwrap()
.iter()
.map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap(), l["locked"].as_bool().unwrap()))
.collect();
assert_eq!(got, vec![(f, 0, false), (lk, 1, true), (over, 1, false), (g, 1, false), (a, 2, false)]);
ex(&mut s, "select.set", json!({"ids": [lk]}));
assert_eq!(s.selection(), vec![lk]);
ex(&mut s, "select.none", json!({}));
s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, false)).unwrap();
s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, false)).unwrap();
assert_eq!(s.selection(), vec![over], "a click still skips the locked layer");
}
+12
View File
@@ -995,6 +995,18 @@ pub fn disabled(resp: &Response) {
node(resp, "", |n| n.set_disabled());
}
/// `resp`'s node also says `text` (a menu row's state, e.g. "locked"), after any description
/// already set (joined with ", ").
pub fn describe(resp: &Response, text: &str) {
node(resp, "", |n| {
let d = match n.description() {
Some(old) if !old.is_empty() => format!("{old}, {text}"),
_ => text.to_string(),
};
n.set_description(d);
});
}
/// What a layer row says besides its name.
pub struct RowInfo<'a> {
/// The layer's type ("Frame", "Text").
+99
View File
@@ -127,3 +127,102 @@ fn copying_a_link_from_a_frames_name_label_links_that_frame() {
crate::menus::invoke(&mut u.app, "edit.copyLink");
assert_eq!(copied.lock().unwrap().last().cloned(), Some(format!("figcraft://node/{}", a.0)));
}
/// Two overlapping rectangles, the upper one locked; the menu opens on the overlap with nothing
/// selected. Returns the app's UI and the ids (lower, locked).
fn overlap_ui() -> (Ui, figcraft_doc::NodeId, figcraft_doc::NodeId) {
let mut s = Session::new();
let lower = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 80, "height": 80, "name": "Lower"})).unwrap());
let upper = id(&s.execute("shape.rectangle", &json!({"x": 120, "y": 120, "width": 80, "height": 80, "name": "Upper"})).unwrap());
s.execute("object.toggleLock", &json!({"ids": [upper], "locked": true})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
(u, lower, upper)
}
/// Measured in Figma's web app, 2026-11-10 (Lock and unlock layers: locked layers are listed in Select layer).
#[test]
fn select_layer_marks_locked_layers_with_a_padlock_and_picks_them() {
let (mut u, lower, upper) = overlap_ui();
let at = u.app.to_screen(150.0, 150.0);
crate::canvas::open_context_menu(&mut u.app, at, false);
u.app.session.set_selection(vec![]);
crate::menus::open_select_layer(&u.ctx, true);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
// The Layers panel lists the same names; the menu's rows are the right-most copies.
let menu_row = |name: &str| {
let (_, pos, size) = crate::live_tests::texts(&shapes)
.into_iter()
.filter(|(t, _, _)| t == name)
.max_by(|a, b| a.1.x.total_cmp(&b.1.x))
.unwrap_or_else(|| panic!("no row {name:?}"));
(pos, size)
};
let (lower_at, upper_at) = (menu_row("Lower"), menu_row("Upper"));
assert!(lower_at.0.x > 300.0 && upper_at.0.x > 300.0, "both layers are listed in the menu");
let in_leading = |(pos, size): (Pos2, egui::Vec2)| {
let c = pos + size / 2.0;
shapes
.iter()
.filter(|s| !matches!(s, egui::Shape::Text(_)))
.filter(|s| {
let r = s.visual_bounding_rect();
r.is_positive()
&& (r.center().y - c.y).abs() < 8.0
&& r.max.x < c.x - size.x / 2.0 - 4.0
&& r.min.x > c.x - size.x / 2.0 - 40.0
&& r.width() < 20.0
})
.count()
};
assert!(in_leading(upper_at) > 0, "the locked row has a padlock in its leading column");
assert_eq!(in_leading(lower_at), 0, "the other row has nothing there");
let p = upper_at.0 + upper_at.1 / 2.0;
for pressed in [true, false] {
u.frame(vec![
egui::Event::PointerMoved(p),
egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE },
]);
}
assert_eq!(u.app.session.selection(), vec![upper], "picking the locked row selects it");
let _ = lower;
}
/// The padlock is only paint: the locked row says "locked" to assistive technology too.
#[test]
fn select_layer_locked_row_is_described_as_locked() {
let (mut u, _, _) = overlap_ui();
let at = u.app.to_screen(150.0, 150.0);
crate::canvas::open_context_menu(&mut u.app, at, false);
u.app.session.set_selection(vec![]);
crate::menus::open_select_layer(&u.ctx, true);
u.ctx.enable_accesskit();
u.frame(vec![]);
let nodes: Vec<egui::accesskit::Node> =
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
let row = |name: &str| {
nodes
.iter()
.find(|n| n.role() == egui::accesskit::Role::MenuItem && n.label() == Some(name))
.unwrap_or_else(|| panic!("no menu row {name:?}"))
};
assert!(row("Upper").description().is_some_and(|d| d.contains("locked")), "the locked row says locked");
assert!(!row("Lower").description().is_some_and(|d| d.contains("locked")), "the other row doesn't");
}
/// Measured on a single locked layer only (Figma's web app, 2026-11-10): the least certain rule.
#[test]
fn select_layer_needs_more_than_one_layer_under_the_pointer() {
let (mut u, _, _) = overlap_ui();
let one = u.app.to_screen(105.0, 105.0);
crate::canvas::open_context_menu(&mut u.app, one, false);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(!has_text(&shapes, "Select layer"), "one layer under the pointer: no Select layer");
let two = u.app.to_screen(150.0, 150.0);
crate::canvas::open_context_menu(&mut u.app, two, false);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "Select layer"), "two layers: it shows");
}
+15 -8
View File
@@ -15,7 +15,8 @@ pub fn open_select_layer(ctx: &egui::Context, open: bool) {
}
/// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the
/// click, nested ones indented, the selected ones checked. Returns whether one was picked.
/// click (locked ones with a padlock), nested ones indented, the selected ones checked; shown
/// only with more than one layer under the pointer. Returns whether one was picked.
pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) -> bool {
if !app.canvas.rect.contains(pos) {
return false;
@@ -23,15 +24,21 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
let p = app.to_page(pos);
let Ok(ds) = app.session.ds() else { return false };
let tol = 2.0 / ds.view.zoom.max(1e-6);
let layers: Vec<(figcraft_doc::NodeId, usize, String)> =
figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol, Some(&figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection)))
let opts = figcraft_doc::geometry::HitOpts { locked: true };
let layers: Vec<(figcraft_doc::NodeId, usize, String, bool)> = {
let result = figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection);
figcraft_doc::geometry::layers_at_opts(&ds.doc, ds.page, p, tol, Some(&result), opts)
.into_iter()
.take(40)
.filter_map(|(id, depth)| {
ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() }))
ds.doc.node(id).map(|n| {
let name = if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() };
(id, depth, name, figcraft_engine::tools::locked_in(&ds.doc, id))
})
})
.collect();
if layers.is_empty() {
.collect()
};
if layers.len() < 2 {
return false;
}
let sel = ds.selection.clone();
@@ -40,8 +47,8 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
SubmenuOptions { area: Id::unique("ctx_select_layer"), key: SELECT_LAYER, label: tr("Select layer"), gap: 4.0, constrain: true, tokens: t },
|ui| {
let mut picked = None;
for (id, depth, name) in &layers {
if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
for (id, depth, name, locked) in &layers {
if indented_item(ui, name, "", true, sel.contains(id), *locked, 12.0 * (*depth).min(8) as f32, t) {
picked = Some(*id);
}
}
+10 -3
View File
@@ -395,17 +395,22 @@ fn need_width(ui: &Ui, w: f32) {
}
pub(crate) fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool {
indented_item(ui, label, shortcut, enabled, checked, 0.0, t)
indented_item(ui, label, shortcut, enabled, checked, false, 0.0, t)
}
/// A menu row whose label starts `indent` pixels further in (nested layers).
pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, indent: f32, t: &Tokens) -> bool {
/// A menu row whose label starts `indent` pixels further in (nested layers); `locked` paints a
/// padlock in the leading column (a checked row shows the check instead).
#[allow(clippy::too_many_arguments)]
pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, locked: bool, indent: f32, t: &Tokens) -> bool {
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
a11y::menu_item(&resp, label, shortcut, checked.then_some(true));
if !enabled {
a11y::disabled(&resp);
}
if locked {
a11y::describe(&resp, i18n::tr("locked"));
}
// The keyboard's item is highlighted like the hovered one.
a11y::own_focus(&resp);
let hov = plain_row_hover(ui, &resp) && enabled;
@@ -415,6 +420,8 @@ pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: b
let col = if enabled { t.menu_text } else { t.menu_dim };
if checked {
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, col);
} else if locked {
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Lock, col);
}
let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), col);
let keys = (!shortcut.is_empty())
+2 -2
View File
@@ -2084,8 +2084,8 @@ turned off 0°.
`node.set {pointCount}` keeps stars to 3–60 points; `miterAngle` (degrees, the Design panel's
Miter angle) sets the miter limit (`limit = 1 / sin(angle / 2)`); `get_node` reports a join
other than miter (`strokeJoin`) or a miter angle other than the default ~28.96° (`miterAngle`).
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its
depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
- **Select**: `select.layersAt {x, y}` lists every layer under a page point, locked ones too, front-most first with its
depth and a `locked` flag (true when the layer or an ancestor is locked; the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter
/ Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true`
clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch,
and `shift` drags toggle what they touch. A press on the background of a top-level frame with
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: a double-click on a layer's icon zooms to it instead of renaming, measured in Figma's web app on 2026-11-12; M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M8: a double-click on a layer's icon zooms to it instead of renaming, measured in Figma's web app on 2026-11-12; Select layer lists locked layers with a padlock and selects them, and shows only with more than one layer under the pointer (#1560); M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text Line truncated
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -61,7 +61,7 @@ interaction feel only partly compared: the interactions measured so far, case by
|---|---|---|
| Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to the dominant axis with no 45° snap (#498; `shift_drag_moves_the_selection`; measured in Figma's web app, 2026-11-08); a marquee selects the top-level layers it touches, by the box and a rotated shape by its outline (rectangle and ellipse measured, the other shapes by the same code); a partly covered top-level frame or section gives the layers in it the marquee touches, an enclosed one is selected itself, an empty frame on touch; a drag from a frame's background selects that frame only when the marquee also takes another top-level frame (then whole), and also takes what it touches outside it; selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole too); locked and hidden layers are skipped (`marquee_behaviour_tests`, `selection_behaviour_tests`; measured in Figma's web app, 2026-11-11; Shift and Cmd/Ctrl marquees not measured) (a locked layer selected from the panel ignores the arrow keys and the nudge commands; its properties still edit; measured in Figma's web app, 2026-11-10) |
| Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | |
| Select layer menu (every layer under the click) | done | |
| Select layer menu (every layer under the click) | done | shown only with more than one layer under the pointer; locked layers are listed with a padlock in the leading column and are selectable there, as Lock and unlock layers' note says (measured in Figma's web app, 2026-11-10; the "more than one layer" gate was measured on a single locked layer only) (`select_layer_marks_locked_layers_with_a_padlock_and_picks_them`, `select_layer_needs_more_than_one_layer_under_the_pointer`) |
| Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) |
| Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming |
| Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`); Shift keeps the ratio (a corner by the axis dragged most, an edge scaling the other axis about the opposite edge's middle), Alt keeps the centre, Shift+Alt does both, each read afresh on every move (`handle_drags_resize_by_the_documented_rules`; measured in Figma's web app, 2026-11-08; Ctrl/Cmd while dragging not measurable) |
@@ -215,6 +215,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | patch | M8: a double-click on a layer's icon in the Layers panel selects the layer and zooms the canvas to it (`view.zoomSelection`) and starts no rename, as measured in Figma's web app on 2026-11-12; a double-click on the name or elsewhere on the row still renames; the icon's hit area is the rect the row paints the icon in (`a_double_click_on_a_layers_icon_zooms_to_it_instead_of_renaming`), Rename row |
| 2026-11-12 | patch | M5, #1560: the context menu's Select layer lists locked layers with a padlock in the leading column (the Layers panel's padlock icon; a row both selected and locked shows the check) and picking one selects it, as Lock and unlock layers' note says and as measured in Figma's web app, 2026-11-10; the submenu shows only with more than one layer under the pointer (measured on a single locked layer only: the least certain rule). Left clicks, hover and marquee still never reach a locked layer. `select.layersAt` lists locked layers too and its rows gain `locked`. Tests: `layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them`, `select_layer_marks_locked_layers_with_a_padlock_and_picks_them`, `select_layer_needs_more_than_one_layer_under_the_pointer`. Counts unchanged |
| 2026-11-09 | patch | Zoom to selection: the selection is fitted with a 40 pt margin into the canvas above the floating bottom toolbar (`ViewState::bottom_inset`, set by the UI each frame, never saved; `view.set` takes `bottomInset`) and centred in that area, as measured in Figma's web app on 2026-11-09. Zoom to fit is unchanged (margin not measured yet). Numbers unchanged. |
| 2026-11-13 | patch | M1: Shift+Cmd/Ctrl+V (Paste over selection) puts the pasted layers just above the selected layer, in its parent and in the copied order, at its position; they went to the top of the parent's layers, so in an auto layout frame they ended the flow instead of following the selected layer. |
| 2026-11-12 | patch | Drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis with no 45° snap, Alt copies, Shift keeps a resize's ratio (an edge scales about the opposite edge's middle), Alt keeps the centre, Shift+Alt does both, and a modifier changed mid-drag is read fresh on each move; FigCraft matches in all six, Ctrl/Cmd while dragging could not be measured; no code change | this page |