M1: Set default properties gives new shapes and text the layer's properties (#2028)

Edit ▸ Set default properties put the selected layer's properties into the Paste properties
clipboard and did nothing else: a rectangle drawn afterwards was still the default grey, and what
Copy properties had copied was replaced.

The defaults are now kept apart (`Session::default_props`: one set for shapes, set from any layer
but text, and one for text). Rectangles, ellipses, polygons and stars drawn or clicked with their
tools take the shape defaults: fills, strokes and their settings, effects, opacity, blend and
corners (only where the shape has corners), through `apply_copied_props`, the rules Paste
properties already used (now shared). New text from the Text tool takes a text layer's fills and
font and is laid out at that size. Frames, sections, slices, lines, arrows, vectors and layers made
by commands keep their own look; the help center is silent on those. `edit.setDefaultProperties`
answers `{for: shapes|text, properties}`.

Tests: `default_props_tests::new_shapes_drawn_with_the_tools_take_the_default_properties`,
`setting_defaults_leaves_the_copied_properties_alone`,
`new_text_takes_a_text_layers_defaults_and_shapes_keep_theirs`. Docs: ui-parity.md, ROADMAP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2LxvJ6MuFmiLA6Zqm
This commit is contained in:
Filip Strajnar
2026-11-06 12:55:21 +01:00
co-authored by Claude Opus 5.5
parent 8eb0b87646
commit 20f1219fbc
10 changed files with 200 additions and 24 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+12 -5
View File
@@ -193,6 +193,7 @@ impl Session {
}
// New text at the click (Figma puts the baseline-ish top-left there).
let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page);
let defaults = self.default_props.text.clone();
let ds = self.ds_mut()?;
let d = ds.doc_mut();
let id = d.alloc_id();
@@ -204,12 +205,18 @@ impl Session {
if let Some(fills) = readable_text_fills(d, parent) {
n.fills = fills;
}
if let Some((w, h)) = figcraft_text::auto_size(&n) {
n.width = w.max(1.0);
n.height = h;
}
let line = n.height;
d.insert(n, parent, None)?;
// Edit ▸ Set default properties from a text layer: its fills and font (best effort).
if let Some(defaults) = &defaults {
let _ = crate::cmd::edit::apply_copied_props("edit.setDefaultProperties", d, id, defaults);
}
let line = d.node_mut(id).map_or(14.0, |n| {
if let Some((w, h)) = figcraft_text::auto_size(n) {
n.width = w.max(1.0);
n.height = h;
}
n.height
});
ds.selection = vec![id];
textedit::begin(self, id, Some(0), true)?;
self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line };
+11
View File
@@ -38,6 +38,13 @@ impl Session {
let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
let mut created = id;
let page = self.page()?;
// Edit ▸ Set default properties: what new shapes take (not frames, sections, slices, lines
// or arrows, which keep their own look).
let defaults = if matches!(tool, ToolId::Rectangle | ToolId::Ellipse | ToolId::Polygon | ToolId::Star) {
self.default_props.shape.clone()
} else {
None
};
self.preview(|doc, sel| {
let (mut a, mut b) = (start, p);
if pixel {
@@ -89,6 +96,10 @@ impl Session {
n.transform = pw.inverse() * Affine::translate((r.x0, r.y0));
}
doc.insert(n, parent, None)?;
if let Some(d) = &defaults {
// Best effort: a default the shape can't take leaves it as drawn.
let _ = crate::cmd::edit::apply_copied_props("edit.setDefaultProperties", doc, nid, d);
}
*sel = vec![nid];
created = Some(nid);
let _ = page;
+21 -6
View File
@@ -17,6 +17,7 @@ mod clipboard;
mod props;
mod select;
pub(crate) use clipboard::apply_copied_props;
pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into};
use clipboard::{copy, copy_props, copy_props_of, cut, paste, paste_design_layers, paste_over, paste_props, paste_replace};
use props::{
@@ -142,12 +143,26 @@ pub fn specs() -> Vec<CommandSpec> {
true,
find_replace
),
spec!("edit.setDefaultProperties", "Set default properties", "{id?}", false, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
let v = copy_props_of(s.doc()?, id);
s.clipboard.props = Some(v);
ok()
}),
spec!(
"edit.setDefaultProperties",
"Set default properties",
"{id?} the layer's fills, strokes, effects, opacity and corners (a text layer's: also its font) become what new rectangles, ellipses, polygons and stars (or, from a text layer, new text) drawn with the tools take → {for: shapes|text, properties}",
false,
|s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
let doc = s.doc()?;
let text = matches!(doc.get(id)?.kind, NodeKind::Text(_));
let v = copy_props_of(doc, id);
let kind = if text {
s.default_props.text = Some(v.clone());
"text"
} else {
s.default_props.shape = Some(v.clone());
"shapes"
};
Ok(json!({"for": kind, "properties": v}))
}
),
spec!("select.set", "Select", "{ids: [..]} (replace the selection)", false, |s, p| {
let ids = match get(p, "ids") {
Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(),
+16 -10
View File
@@ -357,21 +357,27 @@ pub(super) fn copy_props(s: &mut Session, p: &Value) -> Result<Value> {
Ok(v)
}
/// Give layer `id` the properties [`copy_props_of`] copied (`props`), those it can take: the text
/// properties only to text, the corners only to a layer with corners.
pub(crate) fn apply_copied_props(cmd: &str, doc: &mut Document, id: NodeId, props: &Value) -> Result<()> {
let mut m = props.as_object().cloned().unwrap_or_default();
let is_text = matches!(doc.get(id)?.kind, NodeKind::Text(_));
if !is_text {
m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing");
}
if !doc.get(id)?.kind.has_corner_radius() {
m.remove("cornerRadius");
m.remove("cornerSmoothing");
}
crate::cmd::props::apply(cmd, doc, id, &m)
}
pub(super) fn paste_props(s: &mut Session, p: &Value) -> Result<Value> {
let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?;
let ids = req_targets("edit.pasteProperties", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
let mut m = props.as_object().cloned().unwrap_or_default();
let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_));
if !is_text {
m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing");
}
if !doc.get(i)?.kind.has_corner_radius() {
m.remove("cornerRadius");
m.remove("cornerSmoothing");
}
crate::cmd::props::apply("edit.pasteProperties", doc, i, &m)?;
apply_copied_props("edit.pasteProperties", doc, i, &props)?;
}
ok()
}
+117
View File
@@ -0,0 +1,117 @@
//! Edit ▸ Set default properties (#2028): new rectangles, ellipses, polygons and stars drawn with
//! the tools take the properties of the layer it was set from, and new text takes a text layer's;
//! it no longer only copied them for Paste properties.
use figcraft_color::Color;
use figcraft_doc::{Node, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind, ToolId};
/// Run `cmd`, failing the test on an error.
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// The layer `id` as it is now.
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().node(id).unwrap().clone()
}
/// Draw with `tool` from `a` to `b` (a click when they're equal); the new layer.
fn draw(s: &mut Session, tool: ToolId, a: (f64, f64), b: (f64, f64)) -> NodeId {
s.set_tool(tool);
s.pointer(&PointerEvent::at(PointerKind::Down, a.0, a.1, 1.0)).unwrap();
if a != b {
s.pointer(&PointerEvent::at(PointerKind::Move, (a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, b.0, b.1, 1.0)).unwrap();
}
s.pointer(&PointerEvent::at(PointerKind::Up, b.0, b.1, 1.0)).unwrap();
*s.selection().first().unwrap()
}
/// The first solid fill's colour of layer `n`.
fn fill(n: &Node) -> Option<Color> {
n.fills.first().and_then(|p| p.solid_color())
}
/// A red, half-transparent rectangle with a 4 pt blue stroke and 12 pt corners.
fn styled(s: &mut Session) -> NodeId {
let id = id_of(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(s, "node.set", json!({"id": id, "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 4, "opacity": 0.5, "cornerRadius": 12}}));
id
}
/// Rectangles, ellipses, polygons and stars drawn or clicked with their tools take the shape
/// defaults (corners only where a shape has them, its own drawn size); frames, lines and layers made
/// by commands keep their own look; undo takes a drawn shape away whole.
#[test]
fn new_shapes_drawn_with_the_tools_take_the_default_properties() {
let mut s = Session::new();
let source = styled(&mut s);
let set = ex(&mut s, "edit.setDefaultProperties", json!({"id": source}));
assert_eq!(set["for"], "shapes");
let red = Some(Color::from_hex("#FF0000").unwrap());
// Dragged and clicked (a click places a 100×100 shape) alike.
for (tool, a, b) in
[(ToolId::Rectangle, (200.0, 0.0), (300.0, 80.0)), (ToolId::Ellipse, (400.0, 0.0), (400.0, 0.0)), (ToolId::Star, (600.0, 0.0), (660.0, 60.0))]
{
let id = draw(&mut s, tool, a, b);
let n = node(&s, id);
assert_eq!(fill(&n), red, "{tool:?}");
assert_eq!((n.stroke.weight, n.opacity), (4.0, 0.5), "{tool:?}");
assert_eq!(n.strokes.first().and_then(|p| p.solid_color()), Some(Color::from_hex("#0000FF").unwrap()), "{tool:?}");
}
let rect = draw(&mut s, ToolId::Rectangle, (0.0, 200.0), (80.0, 260.0));
assert_eq!(node(&s, rect).corners.radii, [12.0; 4], "corners go to layers with corners");
assert_eq!((node(&s, rect).width, node(&s, rect).height), (80.0, 60.0), "the drawn size, not the source's");
// Frames and lines keep their own look; a shape made by a command too.
let frame = draw(&mut s, ToolId::Frame, (0.0, 400.0), (100.0, 500.0));
assert_ne!(fill(&node(&s, frame)), red);
let line = draw(&mut s, ToolId::Line, (0.0, 600.0), (100.0, 600.0));
assert_ne!(node(&s, line).stroke.weight, 4.0);
let made = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 700, "width": 10, "height": 10}))["id"]).unwrap();
assert_ne!(fill(&node(&s, made)), red);
// Undo takes a drawn shape away whole, defaults included.
let drawn = draw(&mut s, ToolId::Polygon, (800.0, 0.0), (860.0, 60.0));
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(drawn).is_none());
}
/// Setting defaults no longer overwrites what Copy properties copied.
#[test]
fn setting_defaults_leaves_the_copied_properties_alone() {
let mut s = Session::new();
let copied = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": copied, "props": {"fill": "#00FF00"}}));
ex(&mut s, "edit.copyProperties", json!({"id": copied}));
let source = styled(&mut s);
ex(&mut s, "edit.setDefaultProperties", json!({"id": source}));
let target = id_of(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 100, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "edit.pasteProperties", json!({"ids": [target]}));
assert_eq!(fill(&node(&s, target)), Some(Color::from_hex("#00FF00").unwrap()), "Paste properties pastes what was copied");
}
/// A text layer sets the defaults of new text (its font and fills, laid out at that size), and the
/// shapes' defaults stay as they were.
#[test]
fn new_text_takes_a_text_layers_defaults_and_shapes_keep_theirs() {
let mut s = Session::new();
let shape = styled(&mut s);
ex(&mut s, "edit.setDefaultProperties", json!({"id": shape}));
let text = id_of(&ex(&mut s, "text.create", json!({"text": "Title", "x": 0, "y": 200}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": text, "props": {"fontSize": 32, "fill": "#008000"}}));
let set = ex(&mut s, "edit.setDefaultProperties", json!({"id": text}));
assert_eq!(set["for"], "text");
let id = draw(&mut s, ToolId::Text, (300.0, 300.0), (300.0, 300.0));
let n = node(&s, id);
let NodeKind::Text(t) = &n.kind else { panic!("a text layer") };
assert_eq!(t.style.font_size, 32.0);
assert_eq!(fill(&n), Some(Color::from_hex("#008000").unwrap()));
assert!(n.height > 30.0, "laid out at the default size: {}", n.height);
// The shapes' defaults are still the rectangle's.
let rect = draw(&mut s, ToolId::Rectangle, (0.0, 500.0), (80.0, 560.0));
assert_eq!(fill(&node(&s, rect)), Some(Color::from_hex("#FF0000").unwrap()));
}
+5 -1
View File
@@ -110,7 +110,7 @@ mod view_state;
pub use derived::{changed_nodes, normalize};
pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview};
pub use error::{EngineError, Result, bad};
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale};
pub use prefs::{Clipboard, DefaultProps, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale};
pub use session::Forward;
pub use view_state::ViewState;
@@ -119,6 +119,8 @@ pub struct Session {
pub docs: Vec<DocState>,
pub active: usize,
pub clipboard: Clipboard,
/// Edit ▸ Set default properties: what new shapes and text drawn with the tools take.
pub default_props: DefaultProps,
pub prefs: Prefs,
/// A command changed a preference that is kept at once (not only when the app quits): the app
/// saves the preferences and clears this.
@@ -275,6 +277,8 @@ mod dash_cap_tests;
#[cfg(test)]
mod datafill_tests;
#[cfg(test)]
mod default_props_tests;
#[cfg(test)]
mod design_paste_tests;
#[cfg(test)]
mod dev_measurements_tests;
+12
View File
@@ -173,6 +173,18 @@ pub struct UiRequest {
pub data: Value,
}
/// What Edit ▸ Set default properties keeps: the properties new layers drawn with the tools take,
/// as Copy properties copies them (fills, strokes and their settings, effects, opacity, blend,
/// corners; for text also the font, size, line height and letter spacing). One set for shapes
/// (rectangles, ellipses, polygons and stars), set from any layer but text, and one for text.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DefaultProps {
/// For new rectangles, ellipses, polygons and stars.
pub shape: Option<Value>,
/// For new text.
pub text: Option<Value>,
}
/// Clipboard contents (internal; the UI mirrors to the system clipboard).
#[derive(Clone, Debug, Default)]
pub struct Clipboard {
+1
View File
@@ -14,6 +14,7 @@ impl Session {
docs: Vec::new(),
active: 0,
clipboard: Clipboard::default(),
default_props: Default::default(),
prefs: Prefs::default(),
prefs_unsaved: false,
tool: tools::ToolState::default(),
+3 -1
View File
File diff suppressed because one or more lines are too long