mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 22:02:38 +00:00
Merge upstream/main into feat/tidy-up-figma-rule
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
commit
8de179754b
6 files changed
+235
-21
No files matched your search
@@ -283,25 +283,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| {
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.bounds_of(&ds.selection) {
|
||||
ds.view.fit(b, 256.0);
|
||||
ds.view.fit_selection(b, 256.0);
|
||||
}
|
||||
Ok(json!({"zoom": ds.view.zoom}))
|
||||
}),
|
||||
spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| {
|
||||
let v = view(s)?;
|
||||
if let Some(z) = num(p, "zoom") {
|
||||
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
||||
spec!(
|
||||
"view.set",
|
||||
"Set view",
|
||||
"{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size), bottomInset? (height of the strip the bottom toolbar covers)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let v = view(s)?;
|
||||
if let Some(z) = num(p, "zoom") {
|
||||
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
||||
}
|
||||
v.cx = num_or(p, "cx", v.cx);
|
||||
v.cy = num_or(p, "cy", v.cy);
|
||||
if let Some(w) = num(p, "vw") {
|
||||
v.vw = w.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(h) = num(p, "vh") {
|
||||
v.vh = h.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(i) = num(p, "bottomInset") {
|
||||
v.bottom_inset = i.clamp(0.0, 20000.0);
|
||||
}
|
||||
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
||||
}
|
||||
v.cx = num_or(p, "cx", v.cx);
|
||||
v.cy = num_or(p, "cy", v.cy);
|
||||
if let Some(w) = num(p, "vw") {
|
||||
v.vw = w.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(h) = num(p, "vh") {
|
||||
v.vh = h.clamp(10.0, 20000.0);
|
||||
}
|
||||
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
||||
}),
|
||||
),
|
||||
spec!(
|
||||
"view.zoomAt",
|
||||
"Zoom tool",
|
||||
|
||||
@@ -131,7 +131,7 @@ fn zoom_to_fit_selection_and_frames() {
|
||||
// (command, selection before, zoom, centre, selection after, derivation)
|
||||
let rows: Vec<(&str, Vec<NodeId>, f64, (f64, f64), Vec<NodeId>, &str)> = vec![
|
||||
("view.zoomFit", vec![], fit(content), (470.0, 300.0), vec![], "Shift+1 fits everything on the page into the view"),
|
||||
("view.zoomSelection", vec![r], fit(Rect::new(0.0, 0.0, 40.0, 40.0)), (920.0, 20.0), vec![r], "Shift+2 zooms to the selection"),
|
||||
("view.zoomSelection", vec![r], (800.0 - 80.0) / 40.0, (920.0, 20.0), vec![r], "Shift+2 zooms to the selection, with a 40-point margin"),
|
||||
(
|
||||
"view.nextFrame",
|
||||
vec![],
|
||||
@@ -225,7 +225,7 @@ fn view_shortcuts_follow_the_documented_keys() {
|
||||
(Platform::Other, "-", m(true, false, false, false), "view.zoomOut", "Shift and - zooms out"),
|
||||
(Platform::Mac, "0", m(true, false, false, false), "view.zoom100", "Shift+0 zooms to 100%"),
|
||||
(Platform::Other, "1", m(true, false, false, false), "view.zoomFit", "Shift+1 zooms to fit"),
|
||||
(Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection"),
|
||||
(Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection, with a 40-point margin"),
|
||||
(Platform::Mac, "N", m(false, false, false, false), "view.nextFrame", "N zooms to the next frame"),
|
||||
(Platform::Other, "N", m(true, false, false, false), "view.prevFrame", "Shift+N zooms to the previous frame"),
|
||||
(Platform::Mac, "R", m(true, false, false, false), "view.rulers", "Shift+R shows and hides the rulers"),
|
||||
@@ -771,3 +771,151 @@ fn view_toggles_are_kept_for_the_next_launch() {
|
||||
}
|
||||
assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}");
|
||||
}
|
||||
|
||||
/// Select a fresh rectangle at the origin, set the view and run `cmd`; the rect's screen box is
|
||||
/// returned as (left, top, right, bottom).
|
||||
fn zoom_on(cmd: &str, inset: f64, w: f64, h: f64) -> (ViewState, (f64, f64, f64, f64)) {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, w, h, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": inset}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, cmd, json!({}));
|
||||
let v = view(&s);
|
||||
let (l, t) = v.page_to_screen(0.0, 0.0);
|
||||
let (rr, b) = v.page_to_screen(w, h);
|
||||
(v, (l, t, rr, b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_leaves_forty_points_and_clears_the_toolbar() {
|
||||
// Measured in Figma's web app, 2026-11-09: 40 points on every side of the canvas above the toolbar.
|
||||
let (v, (l, t, r, b)) = zoom_on("view.zoomSelection", 58.0, 100.0, 100.0);
|
||||
assert!((v.zoom - 7.65).abs() < 0.01, "{}", v.zoom);
|
||||
assert!((t - 40.0).abs() < 0.5 && (b - (845.0 - 40.0)).abs() < 0.5, "top {t}, bottom {b}");
|
||||
assert!((l - (1389.0 - r)).abs() < 0.5, "left {l}, right {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_on_a_wide_layer_is_limited_by_the_width() {
|
||||
let (v, (l, _, r, _)) = zoom_on("view.zoomSelection", 58.0, 1000.0, 200.0);
|
||||
assert!((v.zoom - (1389.0 - 80.0) / 1000.0).abs() < 0.001, "{}", v.zoom);
|
||||
assert!((l - 40.0).abs() < 0.5 && ((1389.0 - r) - 40.0).abs() < 0.5, "left {l}, right {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_without_a_toolbar_inset_uses_the_whole_height() {
|
||||
let (v, _) = zoom_on("view.zoomSelection", 0.0, 100.0, 100.0);
|
||||
assert!((v.zoom - (903.0 - 80.0) / 100.0).abs() < 1e-6, "{}", v.zoom);
|
||||
let (_, top) = v.page_to_screen(0.0, 0.0);
|
||||
let (_, bottom) = v.page_to_screen(100.0, 100.0);
|
||||
assert!((top - 40.0).abs() < 1e-6 && (bottom - 863.0).abs() < 1e-6, "top {top}, bottom {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_in_a_short_canvas_still_ends_above_the_toolbar() {
|
||||
// A canvas 130 points high with the 60-point toolbar strip leaves 70 above it: too short for
|
||||
// 40 points a side, so the margin shrinks and the selection stays clear of the toolbar.
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 130, "bottomInset": 60}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
let (_, top) = v.page_to_screen(0.0, 0.0);
|
||||
let (_, bottom) = v.page_to_screen(100.0, 100.0);
|
||||
assert!(top >= 0.0 && bottom <= 70.0 + 1e-6, "top {top}, bottom {bottom}");
|
||||
assert!((top - (70.0 - bottom)).abs() < 1e-6, "centred above the toolbar: top {top}, bottom {bottom}");
|
||||
// The margin shrinks to a quarter of the 70 points (17.5) and the zoom is the exact fit, not the floor.
|
||||
assert!((v.zoom - 0.35).abs() < 1e-9, "{}", v.zoom);
|
||||
assert!((top - 17.5).abs() < 1e-6 && (bottom - 52.5).abs() < 1e-6, "top {top}, bottom {bottom}");
|
||||
}
|
||||
|
||||
/// A canvas narrower than 160 points keeps a quarter of its width on each side.
|
||||
#[test]
|
||||
fn zoom_to_selection_in_a_narrow_canvas_shrinks_the_side_margins() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 100, "vh": 903, "bottomInset": 0}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
let (left, _) = v.page_to_screen(0.0, 0.0);
|
||||
let (right, _) = v.page_to_screen(100.0, 100.0);
|
||||
assert!((v.zoom - 0.5).abs() < 1e-9, "{}", v.zoom);
|
||||
assert!((left - 25.0).abs() < 1e-6 && (right - 75.0).abs() < 1e-6, "left {left}, right {right}");
|
||||
}
|
||||
|
||||
/// A view 1000 by 800 with a 100-point toolbar strip, for the edge cases of the fit.
|
||||
fn strip_view() -> ViewState {
|
||||
ViewState { vw: 1000.0, vh: 800.0, bottom_inset: 100.0, ..ViewState::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_a_selection_survives_points_lines_extremes_and_junk() {
|
||||
// A point: the zoom stays, and it lands in the middle of the area above the toolbar (y = 350).
|
||||
let mut v = strip_view();
|
||||
v.zoom = 2.0;
|
||||
v.fit_selection(Rect::new(10.0, 20.0, 10.0, 20.0), 32.0);
|
||||
assert_eq!(v.zoom, 2.0);
|
||||
let (x, y) = v.page_to_screen(10.0, 20.0);
|
||||
assert!((x - 500.0).abs() < 1e-9 && (y - 350.0).abs() < 1e-9, "{x}, {y}");
|
||||
// A zero-width line is limited by its height, a zero-height one by its width.
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 0.0, 100.0), 32.0);
|
||||
assert!((v.zoom - (700.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom);
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 100.0, 0.0), 32.0);
|
||||
assert!((v.zoom - (1000.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom);
|
||||
// A tiny one stops at the maximum zoom, a huge one at the minimum.
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 0.001, 0.001), 32.0);
|
||||
assert_eq!(v.zoom, 32.0);
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 1e12, 1e12), 32.0);
|
||||
assert_eq!(v.zoom, ViewState::MIN_ZOOM);
|
||||
// A rectangle with any coordinate that is not a finite number changes nothing.
|
||||
for bad in [
|
||||
Rect::new(0.0, f64::NAN, 10.0, 10.0),
|
||||
Rect::new(0.0, 0.0, f64::INFINITY, 10.0),
|
||||
Rect::new(0.0, 0.0, 10.0, f64::NAN),
|
||||
Rect::new(f64::NAN, 0.0, 10.0, 10.0),
|
||||
] {
|
||||
let mut v = strip_view();
|
||||
let before = (v.zoom, v.cx, v.cy);
|
||||
v.fit_selection(bad, 32.0);
|
||||
assert_eq!((v.zoom, v.cx, v.cy), before, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bottom_inset_is_clamped_and_never_saved() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "view.set", json!({"bottomInset": -5}));
|
||||
assert_eq!(view(&s).bottom_inset, 0.0);
|
||||
ex(&mut s, "view.set", json!({"bottomInset": 1e12}));
|
||||
assert_eq!(view(&s).bottom_inset, 20000.0);
|
||||
// Larger than the viewport: the fit still leaves a sliver of canvas and a finite centre.
|
||||
ex(&mut s, "view.set", json!({"vw": 400, "vh": 300, "bottomInset": 5000}));
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
assert!(v.zoom.is_finite() && v.cx.is_finite() && v.cy.is_finite(), "{v:?}");
|
||||
// It is not part of what the view saves.
|
||||
let saved = serde_json::to_string(&v).unwrap();
|
||||
assert!(!saved.to_lowercase().contains("inset"), "{saved}");
|
||||
let back: ViewState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(back.bottom_inset, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fit_is_unchanged() {
|
||||
// Left alone on purpose: its 64-point margin is not measured against Figma yet.
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": 58}));
|
||||
ex(&mut s, "view.zoomFit", json!({}));
|
||||
let v = view(&s);
|
||||
assert!((v.zoom - (903.0 - 128.0) / 100.0).abs() < 1e-6);
|
||||
// No toolbar offset: the rectangle's own centre stays in the middle of the whole viewport.
|
||||
assert_eq!((v.cx, v.cy), (50.0, 50.0));
|
||||
}
|
||||
@@ -12,6 +12,10 @@ pub struct ViewState {
|
||||
/// Viewport size in logical pixels (set by the UI; used by zoom-to-fit).
|
||||
pub vw: f64,
|
||||
pub vh: f64,
|
||||
/// Height in screen points of the strip the floating bottom toolbar covers (from its top edge to
|
||||
/// the canvas's bottom edge); set by the UI every frame, 0 when it's hidden. Never saved.
|
||||
#[serde(skip)]
|
||||
pub bottom_inset: f64,
|
||||
pub pixel_grid: bool,
|
||||
pub layout_grids: bool,
|
||||
pub rulers: bool,
|
||||
@@ -51,6 +55,7 @@ impl Default for ViewState {
|
||||
zoom: 1.0,
|
||||
vw: 1200.0,
|
||||
vh: 800.0,
|
||||
bottom_inset: 0.0,
|
||||
pixel_grid: true,
|
||||
layout_grids: true,
|
||||
rulers: false,
|
||||
@@ -105,6 +110,32 @@ impl ViewState {
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y;
|
||||
}
|
||||
/// Fit a page rectangle for Zoom to selection: a 40 point margin on all four sides of the area
|
||||
/// above the bottom toolbar (`vw` by `vh - bottom_inset`), centred in that area.
|
||||
pub fn fit_selection(&mut self, r: figcraft_geom::Rect, max_zoom: f64) {
|
||||
// A rectangle that isn't all finite numbers leaves the view as it is.
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let inset = if self.bottom_inset.is_finite() { self.bottom_inset.clamp(0.0, (self.vh - 1.0).max(0.0)) } else { 0.0 };
|
||||
let area_h = (self.vh - inset).max(1.0);
|
||||
// A point (nothing to fit) is centred in the area above the toolbar at the zoom it has.
|
||||
if !(r.width() > 0.0 || r.height() > 0.0) {
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom.max(1e-6);
|
||||
return;
|
||||
}
|
||||
// 40 points as measured; a canvas too small for that keeps a quarter of its size on each
|
||||
// side instead, so the selection still ends above the toolbar.
|
||||
let mx = (self.vw / 4.0).clamp(0.0, 40.0);
|
||||
let my = (area_h / 4.0).clamp(0.0, 40.0);
|
||||
let zx = (self.vw - mx * 2.0).max(1.0) / r.width().max(1e-6);
|
||||
let zy = (area_h - my * 2.0).max(1.0) / r.height().max(1e-6);
|
||||
self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom);
|
||||
// The rect's centre lands at screen y = area_h / 2, the page centre `(vh - area_h) / 2` below it.
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom;
|
||||
}
|
||||
/// The zoom from which the pixel grid shows (400%).
|
||||
pub const PIXEL_GRID_ZOOM: f64 = 4.0;
|
||||
/// Whether the pixel grid draws: it's on and the canvas is zoomed in to 400% or more.
|
||||
|
||||
@@ -379,6 +379,12 @@ impl FigApp {
|
||||
let worker = self.worker.take();
|
||||
let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone()));
|
||||
canvas::show(self, ui, &mut worker);
|
||||
// The strip the floating bottom toolbar covers, for Zoom to selection (0 when it's hidden).
|
||||
let inset =
|
||||
if !hidden && mobile.is_none() { f64::from(self.canvas.rect.max.y - toolbar::pill_rect(self.canvas.rect, 0.0).min.y) } else { 0.0 };
|
||||
if let Ok(ds) = self.session.ds_mut() {
|
||||
ds.view.bottom_inset = inset;
|
||||
}
|
||||
self.worker = Some(worker);
|
||||
if !hidden && mobile.is_none() {
|
||||
ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| {
|
||||
|
||||
@@ -90,7 +90,14 @@ fn shift_digits_zoom() {
|
||||
press(&mut u, Key::Num2, Modifiers::SHIFT);
|
||||
assert!(zoom(&u) > fit, "Shift+2 fits the smaller selection");
|
||||
let v = &u.app.session.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (110.0, 40.0), "centred on it");
|
||||
// Centred in the canvas above the bottom toolbar: the page centre sits half the toolbar's strip lower.
|
||||
// The strip is the canvas's bottom edge less the pill's top, worked out here, not read back.
|
||||
let strip = f64::from(u.app.canvas.rect.max.y - crate::toolbar::pill_rect(u.app.canvas.rect, 0.0).min.y);
|
||||
assert!(strip > 20.0 && strip < 200.0, "the pill is a toolbar's height: {strip}");
|
||||
assert!((v.bottom_inset - strip).abs() < 1e-3, "the app gives the engine the pill's strip: {} vs {strip}", v.bottom_inset);
|
||||
let expect_cy = 40.0 + strip / 2.0 / v.zoom;
|
||||
assert_eq!(v.cx, 110.0, "centred on it");
|
||||
assert!((v.cy - expect_cy).abs() < 1e-9, "centred above the toolbar: {} vs {expect_cy}", v.cy);
|
||||
// `+` and `-` step the zoom.
|
||||
let z = zoom(&u);
|
||||
press(&mut u, Key::Minus, Modifiers::NONE);
|
||||
@@ -201,3 +208,16 @@ fn shortcuts_panel_does_not_show_on_the_home_screen() {
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen");
|
||||
}
|
||||
|
||||
/// The strip the bottom toolbar covers is given to the engine while the toolbar shows and is zero
|
||||
/// when the interface is hidden.
|
||||
#[test]
|
||||
fn the_toolbar_strip_is_zero_when_the_interface_is_hidden() {
|
||||
let (mut u, _, _) = setup();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.session.ds().unwrap().view.bottom_inset > 0.0, "shown");
|
||||
u.app.session.ds_mut().unwrap().view.ui_hidden = true;
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.ds().unwrap().view.bottom_inset, 0.0, "hidden");
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); 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 show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible lLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); 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 show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when drLine 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:
|
||||
@@ -119,8 +119,7 @@ interaction feel only partly compared: the interactions measured so far, case by
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); Preferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
|
||||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
|
||||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_testsPreferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_testszoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`); Zoom to selection (Shift+2) fits the selection into the canvas above the floating bottom toolbar with a 40 pt margin on all four sides, centred in that area, at a continuous zoom (measured in Figma's web app, 2026-11-09; `zoom_to_selection_leaves_forty_points_and_clears_the_toolbar`, `zoom_to_selection_on_a_wide_layer_is_limited_by_the_width`, `zoom_to_selection_without_a_toolbar_inset_uses_the_whole_height`); Zoom to fit (Shift+1) keeps its 64 pt margin, margin not measured yet (`zoom_to_fit_is_unchanged`) |
|
||||
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only |
|
||||
| Frame labels (rotated, right-to-left), size badge | done | |
|
||||
| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on |
|
||||
@@ -217,6 +216,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, the smaller of a tie, else the mean rounded half up) and keeps the other axis, layers of a row may overlap along X (also in a chain, and a negative gap is raised so no layer reaches the one two before it, measured in Figma's web app on 2026-11-13), and a grid is packed along the axis with more groups; fewer than three layers, layers that all overlap, a diagonal and an irregular layout grey the row out and move nothing. Measured: those rules and the unavailable cases; FigCraft's choices: a 2 x 2 grid as columns, auto layout flow children, the 2,000 layer cap, rotated layers by their box, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_mean_is_rounded_half_up, two_equally_common_gaps_give_the_smaller, an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts, a_grid_is_tidied_by_columns, a_transposed_grid_is_tidied_by_rows, layers_on_a_diagonal_are_unavailable, an_irregular_selection_is_unavailable and the rest). |
|
||||
| 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 |
|
||||
| 2026-11-08 | patch | Smart guides: the object snap threshold is 4.5 screen pixels, measured against Figma (a dragged edge snapped at a gap of 4.4 screen px and not at 4.6, at 100% and at 200% zoom, Snap to pixel grid on); FigCraft used 5. Equal spacing shares the move's threshold so it follows; ruler-guide and rotation-origin snapping keep 5, unmeasured (`transform_behaviour_tests`) |
|
||||
|
||||
Reference in new issue
Block a user