mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 02:30:05 +00:00
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:
co-authored by
Claude Opus 5.5
parent
8eb0b87646
commit
20f1219fbc
+2
-1
File diff suppressed because one or more lines are too long
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user