mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
feat: 为填充、描边和效果添加右键复制粘贴功能
实现了在属性行和分区标题上右键菜单,支持复制粘贴单个填充、描边和效果。新增了相关的命令、测试和国际化文本,完善了剪贴板数据结构以支持这些新功能。
This commit is contained in:
@@ -229,6 +229,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
|
||||
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
|
||||
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
|
||||
spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
|
||||
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
|
||||
spec!(
|
||||
"stroke.copy",
|
||||
"Copy stroke",
|
||||
"{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes",
|
||||
false,
|
||||
|s, p| paint_copy(s, p, false)
|
||||
),
|
||||
spec!(
|
||||
"stroke.paste",
|
||||
"Paste stroke",
|
||||
"{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint",
|
||||
true,
|
||||
|s, p| paint_paste(s, p, false)
|
||||
),
|
||||
spec!(
|
||||
"fill.pattern",
|
||||
"Pattern fill",
|
||||
@@ -276,6 +292,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy),
|
||||
spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste),
|
||||
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
|
||||
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
|
||||
for id in req_targets("grid.add", s, p)? {
|
||||
@@ -608,7 +626,7 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let props = s.clipboard.props.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard =
|
||||
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
|
||||
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, ..crate::Clipboard::default() };
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
@@ -1386,6 +1404,53 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke
|
||||
/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along.
|
||||
fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.copy" } else { "stroke.copy" };
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||
let at = opt_index(cmd, p, "index")?;
|
||||
let (paint, stroke) = {
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(id)?;
|
||||
let list = if fills { &n.fills } else { &n.strokes };
|
||||
let at = at.unwrap_or(list.len().saturating_sub(1));
|
||||
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
|
||||
(paint, (!fills).then(|| n.stroke.clone()))
|
||||
};
|
||||
s.clipboard.paint = Some((fills, paint.clone()));
|
||||
s.clipboard.stroke = stroke;
|
||||
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A
|
||||
/// stroke's copied settings go onto each target too.
|
||||
fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.paste" } else { "stroke.paste" };
|
||||
let Some((copied_fills, paint)) = s.clipboard.paint.clone() else {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into()));
|
||||
};
|
||||
if copied_fills != fills {
|
||||
let what = if fills { "stroke" } else { "fill" };
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}")));
|
||||
}
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
|
||||
let out = match opt_index(cmd, p, "index")? {
|
||||
Some(at) => {
|
||||
q["index"] = json!(at);
|
||||
paint_set(s, &q, fills)?
|
||||
}
|
||||
None => paint_list(s, &q, fills, true)?,
|
||||
};
|
||||
if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) {
|
||||
for id in ids {
|
||||
s.doc_mut()?.get_mut(id)?.stroke = stroke.clone();
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "fill.pattern";
|
||||
let src = req_id(CMD, p, "source")?;
|
||||
@@ -1422,3 +1487,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Copy effect `index` (the top one by default) of the first target into the clipboard.
|
||||
fn effect_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "effect.copy";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?;
|
||||
let at = opt_index(CMD, p, "index")?;
|
||||
let e = {
|
||||
let doc = s.doc()?;
|
||||
let list = &doc.get(id)?.effects;
|
||||
let at = at.unwrap_or(list.len().saturating_sub(1));
|
||||
list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))?
|
||||
};
|
||||
s.clipboard.effect = Some(e.clone());
|
||||
serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string()))
|
||||
}
|
||||
|
||||
/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one.
|
||||
fn effect_paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "effect.paste";
|
||||
let Some(e) = s.clipboard.effect.clone() else {
|
||||
return Err(EngineError::Disabled(CMD.into(), "no copied effect".into()));
|
||||
};
|
||||
let at = opt_index(CMD, p, "index")?;
|
||||
for id in req_targets(CMD, s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
match at {
|
||||
Some(i) => {
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
*slot = e.clone();
|
||||
}
|
||||
}
|
||||
None => n.effects.push(e.clone()),
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -759,6 +759,12 @@ pub struct Clipboard {
|
||||
pub images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
|
||||
/// Copied properties (Copy properties / Paste properties).
|
||||
pub props: Option<Value>,
|
||||
/// A copied fill or stroke row (`fill.copy`, `stroke.copy`): whether it's a fill, and the paint.
|
||||
pub paint: Option<(bool, figcraft_color::Paint)>,
|
||||
/// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint.
|
||||
pub stroke: Option<figcraft_doc::StrokeProps>,
|
||||
/// A copied effect row (`effect.copy`).
|
||||
pub effect: Option<figcraft_doc::Effect>,
|
||||
/// The page-space bounds of the copied selection.
|
||||
pub bounds: Option<figcraft_geom::Rect>,
|
||||
pub source_doc: u64,
|
||||
@@ -1787,6 +1793,8 @@ pub(crate) mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
mod property_copy_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_conditional_tests;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Copying one fill, stroke or effect row (`fill.copy`, `stroke.copy`, `effect.copy`) and pasting
|
||||
//! it onto other layers (`fill.paste`, `stroke.paste`, `effect.paste`): the Design panel's row
|
||||
//! menus.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Effect, EffectKind, NodeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> figcraft_doc::Node {
|
||||
s.doc().unwrap().node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_fill_replaces_a_fill_row_or_adds_one() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
let red = Paint::solid(Color::from_hex("#FF0000").unwrap());
|
||||
ex(&mut s, "fill.add", json!({"ids": [a], "paint": red}));
|
||||
let copied = ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
|
||||
assert_eq!(copied["type"], "solid", "the copy reports the paint it took");
|
||||
// Onto b's fill row: it replaces that paint.
|
||||
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
|
||||
assert_eq!(node(&s, b).fills, vec![red.clone()]);
|
||||
// With no row: it adds one on top.
|
||||
ex(&mut s, "fill.paste", json!({"ids": [b]}));
|
||||
assert_eq!(node(&s, b).fills, [red.clone(), red]);
|
||||
// Each paste is one undo step.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, b).fills.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_stroke_pastes_as_a_stroke_only() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
let blue = Paint::solid(Color::from_hex("#0000FF").unwrap());
|
||||
ex(&mut s, "stroke.add", json!({"ids": [a], "paint": blue}));
|
||||
ex(&mut s, "node.set", json!({"ids": [a], "props": {"strokeWeight": 3.0, "strokeAlign": "outside"}}));
|
||||
ex(&mut s, "stroke.copy", json!({"id": a, "index": 0}));
|
||||
ex(&mut s, "stroke.add", json!({"ids": [b]}));
|
||||
ex(&mut s, "stroke.paste", json!({"ids": [b], "index": 0}));
|
||||
assert_eq!(node(&s, b).strokes, [blue]);
|
||||
assert_eq!(node(&s, b).stroke, node(&s, a).stroke, "the weight and alignment come with the paint");
|
||||
assert_eq!((node(&s, b).stroke.weight, node(&s, b).stroke.align), (3.0, figcraft_doc::StrokeAlign::Outside));
|
||||
// A copied fill doesn't paste as a stroke, and the target keeps its stroke.
|
||||
ex(&mut s, "fill.copy", json!({"id": a}));
|
||||
assert!(s.execute("stroke.paste", &json!({"ids": [b]})).is_err());
|
||||
assert_eq!(node(&s, b).strokes.len(), 1);
|
||||
// A layer with no stroke has nothing to copy.
|
||||
assert!(s.execute("stroke.copy", &json!({"id": b, "index": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_effect_replaces_an_effect_row_or_adds_one() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
ex(&mut s, "effect.add", json!({"ids": [a], "effect": {"type": "dropShadow", "radius": 7, "offset": [2, 3]}}));
|
||||
ex(&mut s, "effect.copy", json!({"id": a, "index": 0}));
|
||||
let e = node(&s, a).effects[0].clone();
|
||||
// Onto b's effect row: it replaces that effect.
|
||||
ex(&mut s, "effect.add", json!({"ids": [b], "type": "layerBlur"}));
|
||||
ex(&mut s, "effect.paste", json!({"ids": [b], "index": 0}));
|
||||
assert_eq!(node(&s, b).effects, vec![e.clone()]);
|
||||
// With no row: it adds one on top.
|
||||
ex(&mut s, "effect.paste", json!({"ids": [b]}));
|
||||
assert_eq!(node(&s, b).effects, [e.clone(), e]);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, b).effects.len(), 1);
|
||||
let Effect { kind: EffectKind::DropShadow(shadow), visible } = node(&s, b).effects[0].clone() else {
|
||||
panic!("the pasted effect is a drop shadow")
|
||||
};
|
||||
assert_eq!((shadow.radius, shadow.offset, visible), (7.0, (2.0, 3.0), true));
|
||||
}
|
||||
@@ -313,7 +313,10 @@ name = "English"
|
||||
"Copy as PNG" = "Copy as PNG"
|
||||
"Copy as SVG" = "Copy as SVG"
|
||||
"Copy code" = "Copy code"
|
||||
"Copy effect" = "Copy effect"
|
||||
"Copy fill" = "Copy fill"
|
||||
"Copy link" = "Copy link"
|
||||
"Copy stroke" = "Copy stroke"
|
||||
"Copy the {label}" = "Copy the {label}"
|
||||
"Copy {value}" = "Copy {value}"
|
||||
"Corner radius" = "Corner radius"
|
||||
@@ -971,7 +974,10 @@ name = "English"
|
||||
"Pass through" = "Pass through"
|
||||
"Paste a link (https://…)" = "Paste a link (https://…)"
|
||||
"Paste a team library link (a view link)" = "Paste a team library link (a view link)"
|
||||
"Paste effect" = "Paste effect"
|
||||
"Paste fill" = "Paste fill"
|
||||
"Paste here" = "Paste here"
|
||||
"Paste stroke" = "Paste stroke"
|
||||
"Paste to replace" = "Paste to replace"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" }
|
||||
"Pattern" = "Pattern"
|
||||
|
||||
@@ -315,7 +315,10 @@ status = "draft — needs native review"
|
||||
"Copy as PNG" = "Copiar como PNG"
|
||||
"Copy as SVG" = "Copiar como SVG"
|
||||
"Copy code" = "Copiar código"
|
||||
"Copy effect" = "Copiar efecto"
|
||||
"Copy fill" = "Copiar relleno"
|
||||
"Copy link" = "Copiar enlace"
|
||||
"Copy stroke" = "Copiar trazo"
|
||||
"Copy the {label}" = "Copiar {label}"
|
||||
"Copy {value}" = "Copiar {value}"
|
||||
"Corner radius" = "Radio de esquina"
|
||||
@@ -972,7 +975,10 @@ status = "draft — needs native review"
|
||||
"Pass through" = "Atravesar"
|
||||
"Paste a link (https://…)" = "Pega un enlace (https://…)"
|
||||
"Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)"
|
||||
"Paste effect" = "Pegar efecto"
|
||||
"Paste fill" = "Pegar relleno"
|
||||
"Paste here" = "Pegar aquí"
|
||||
"Paste stroke" = "Pegar trazo"
|
||||
"Paste to replace" = "Pegar para reemplazar"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { one = "Capas de diseño pegadas; se omitió {n} tipo de contenido", other = "Capas de diseño pegadas; se omitieron {n} tipos de contenido" }
|
||||
"Pattern" = "Patrón"
|
||||
@@ -1894,7 +1900,9 @@ status = "draft — needs native review"
|
||||
"edit.setDefaultProperties" = "Establecer propiedades predeterminadas"
|
||||
"edit.undo" = "Deshacer"
|
||||
"effect.add" = "Añadir efecto"
|
||||
"effect.copy" = "Copiar efecto"
|
||||
"effect.move" = "Mover efecto"
|
||||
"effect.paste" = "Pegar efecto"
|
||||
"effect.remove" = "Quitar efecto"
|
||||
"effect.set" = "Establecer efecto"
|
||||
"export.add" = "Añadir ajuste de exportación"
|
||||
@@ -1922,7 +1930,9 @@ status = "draft — needs native review"
|
||||
"file.saveVersion" = "Guardar en el historial de versiones"
|
||||
"file.versionHistory" = "Mostrar historial de versiones"
|
||||
"fill.add" = "Añadir relleno"
|
||||
"fill.copy" = "Copiar relleno"
|
||||
"fill.move" = "Mover relleno"
|
||||
"fill.paste" = "Pegar relleno"
|
||||
"fill.pattern" = "Relleno de patrón"
|
||||
"fill.remove" = "Quitar relleno"
|
||||
"fill.set" = "Establecer relleno"
|
||||
@@ -2112,7 +2122,9 @@ status = "draft — needs native review"
|
||||
"shapeBuilder.remove" = "Quitar regiones"
|
||||
"stroke.add" = "Añadir trazo"
|
||||
"stroke.brush" = "Pincel"
|
||||
"stroke.copy" = "Copiar trazo"
|
||||
"stroke.move" = "Mover trazo"
|
||||
"stroke.paste" = "Pegar trazo"
|
||||
"stroke.remove" = "Quitar trazo"
|
||||
"stroke.set" = "Establecer trazo"
|
||||
"stroke.widthProfile" = "Perfil de ancho"
|
||||
|
||||
@@ -300,7 +300,10 @@ status = "draft — needs native review"
|
||||
"Copy as PNG" = "复制为 PNG"
|
||||
"Copy as SVG" = "复制为 SVG"
|
||||
"Copy code" = "复制代码"
|
||||
"Copy effect" = "复制效果"
|
||||
"Copy fill" = "复制填充"
|
||||
"Copy link" = "复制链接"
|
||||
"Copy stroke" = "复制描边"
|
||||
"Copy the {label}" = "复制{label}"
|
||||
"Copy {value}" = "复制 {value}"
|
||||
"Corner radius" = "圆角半径"
|
||||
@@ -910,7 +913,10 @@ status = "draft — needs native review"
|
||||
"Pass through" = "穿透"
|
||||
"Paste a link (https://…)" = "粘贴链接(https://…)"
|
||||
"Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)"
|
||||
"Paste effect" = "粘贴效果"
|
||||
"Paste fill" = "粘贴填充"
|
||||
"Paste here" = "粘贴到此处"
|
||||
"Paste stroke" = "粘贴描边"
|
||||
"Paste to replace" = "粘贴以替换"
|
||||
"Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" }
|
||||
"Pattern" = "图案"
|
||||
@@ -1777,7 +1783,9 @@ status = "draft — needs native review"
|
||||
"edit.setDefaultProperties" = "设为默认属性"
|
||||
"edit.undo" = "撤销"
|
||||
"effect.add" = "添加效果"
|
||||
"effect.copy" = "复制效果"
|
||||
"effect.move" = "移动效果"
|
||||
"effect.paste" = "粘贴效果"
|
||||
"effect.remove" = "移除效果"
|
||||
"effect.set" = "设置效果"
|
||||
"export.add" = "添加导出设置"
|
||||
@@ -1804,7 +1812,9 @@ status = "draft — needs native review"
|
||||
"file.saveVersion" = "保存到版本历史"
|
||||
"file.versionHistory" = "显示版本历史"
|
||||
"fill.add" = "添加填充"
|
||||
"fill.copy" = "复制填充"
|
||||
"fill.move" = "移动填充"
|
||||
"fill.paste" = "粘贴填充"
|
||||
"fill.pattern" = "图案填充"
|
||||
"fill.remove" = "移除填充"
|
||||
"fill.set" = "设置填充"
|
||||
@@ -1989,7 +1999,9 @@ status = "draft — needs native review"
|
||||
"shapeBuilder.remove" = "移除区域"
|
||||
"stroke.add" = "添加描边"
|
||||
"stroke.brush" = "画笔"
|
||||
"stroke.copy" = "复制描边"
|
||||
"stroke.move" = "移动描边"
|
||||
"stroke.paste" = "粘贴描边"
|
||||
"stroke.remove" = "移除描边"
|
||||
"stroke.set" = "设置描边"
|
||||
"stroke.widthProfile" = "宽度配置"
|
||||
|
||||
@@ -187,6 +187,8 @@ mod present_tests;
|
||||
#[cfg(test)]
|
||||
mod present_view_tests;
|
||||
#[cfg(test)]
|
||||
mod property_menu_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_editor_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_reorder_tests;
|
||||
|
||||
@@ -10,6 +10,7 @@ use serde_json::json;
|
||||
use crate::colorpicker::PickerTarget;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::list_drag::grip_id;
|
||||
use crate::live_tests::{flat, text_center};
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
@@ -151,6 +152,24 @@ fn adding_a_fill_or_stroke_opens_the_color_picker() {
|
||||
assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![r] }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_sections_header_adds_its_item_but_a_full_one_only_adds_from_the_plus() {
|
||||
let mut s = Session::new();
|
||||
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
// Nothing is stroked yet: clicking anywhere on the Stroke header adds one.
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
click(&mut u, text_center(&shapes, "Stroke"));
|
||||
assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1);
|
||||
// It has a stroke now: the header is no longer a button of its own.
|
||||
u.app.ui.picker = None;
|
||||
u.frame(vec![]);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
click(&mut u, text_center(&shapes, "Stroke"));
|
||||
assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_fill_to_mixed_fills_opens_no_picker() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! The Design panel's property row menus: a secondary click on a fill, stroke or effect row
|
||||
//! copies that one paint or effect, and on another layer's row pastes it there.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, text_center};
|
||||
|
||||
fn id(v: &serde_json::Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap())
|
||||
}
|
||||
|
||||
/// A click with `button` at `p`, and the frames that follow; what the last frame drew.
|
||||
fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec<egui::Shape> {
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
}
|
||||
let mut shapes = Vec::new();
|
||||
for pressed in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)) {
|
||||
let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE });
|
||||
shapes = flat(&u.frame_output(events.into_iter().collect()).shapes);
|
||||
}
|
||||
shapes
|
||||
}
|
||||
|
||||
/// The text a row menu offers, clicked.
|
||||
fn pick(u: &mut Ui, menu: &[egui::Shape], label: &str) {
|
||||
click(u, text_center(menu, label), PointerButton::Primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_row_copies_and_pastes_a_fill() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
s.execute("fill.set", &json!({"ids": [a], "color": "#FF0000"})).unwrap();
|
||||
s.execute("fill.set", &json!({"ids": [b], "color": "#0000FF"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary);
|
||||
pick(&mut u, &menu, crate::i18n::tr("Copy fill"));
|
||||
let copied = u.app.session.clipboard.paint.clone().expect("the fill is on the clipboard");
|
||||
assert_eq!(copied.1, u.app.session.doc().unwrap().node(a).unwrap().fills[0]);
|
||||
// Onto b's fill row: it replaces that fill.
|
||||
u.app.run("select.set", json!({"ids": [b]}));
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let menu = click(&mut u, text_center(&shapes, "0000FF"), PointerButton::Secondary);
|
||||
pick(&mut u, &menu, crate::i18n::tr("Paste fill"));
|
||||
assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_row_copies_and_pastes_a_stroke() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#FF0000"}})).unwrap();
|
||||
s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap();
|
||||
s.execute("stroke.add", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary);
|
||||
pick(&mut u, &menu, crate::i18n::tr("Copy stroke"));
|
||||
u.app.run("select.set", json!({"ids": [b]}));
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let menu = click(&mut u, text_center(&shapes, "000000"), PointerButton::Secondary);
|
||||
pick(&mut u, &menu, crate::i18n::tr("Paste stroke"));
|
||||
let strokes = u.app.session.doc().unwrap().node(b).unwrap().strokes.clone();
|
||||
assert_eq!(strokes.len(), 1, "it replaced b's stroke");
|
||||
assert_eq!(strokes[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().stroke.weight, 3.0, "the weight came with it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effect_row_copies_and_pastes_an_effect() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s);
|
||||
let b = rect(&mut s);
|
||||
s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap();
|
||||
s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let menu = click(&mut u, text_center(&shapes, "Layer blur"), PointerButton::Secondary);
|
||||
pick(&mut u, &menu, crate::i18n::tr("Copy effect"));
|
||||
u.app.run("select.set", json!({"ids": [b]}));
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let menu = click(&mut u, text_center(&shapes, "Drop shadow"), PointerButton::Secondary);
|
||||
pick(&mut u, &menu, crate::i18n::tr("Paste effect"));
|
||||
let effects = u.app.session.doc().unwrap().node(b).unwrap().effects.clone();
|
||||
assert_eq!(effects, u.app.session.doc().unwrap().node(a).unwrap().effects);
|
||||
}
|
||||
@@ -253,6 +253,15 @@ pub fn context_popup(resp: &egui::Response) -> egui::Popup<'static> {
|
||||
egui::Popup::context_menu(resp).width(MENU_MIN_W).style(menu_style)
|
||||
}
|
||||
|
||||
/// A context menu for a property row: a secondary click anywhere over `rect` opens it, even over
|
||||
/// the row's fields (which would otherwise take the click), like the incumbent's row menus.
|
||||
fn row_context_menu(ui: &Ui, rect: Rect, id: Id) -> egui::Popup<'static> {
|
||||
let resp = ui.interact(rect, id, Sense::hover());
|
||||
let open = ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.secondary_clicked());
|
||||
let at_pointer = egui::Popup::menu(&resp).width(MENU_MIN_W).style(menu_style).at_pointer_fixed();
|
||||
at_pointer.open_memory(open.then_some(egui::SetOpenCommand::Bool(true)))
|
||||
}
|
||||
|
||||
pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
||||
menu_row(ui, label, shortcut, 0.0, |_, _, _| {}).clicked()
|
||||
}
|
||||
@@ -1098,12 +1107,15 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
|
||||
let title = if fills { tr("Fill") } else { tr("Stroke") };
|
||||
let empty = list.as_ref().is_none_or(|l| l.is_empty());
|
||||
let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };
|
||||
if let Some(i) = widgets::section_header(
|
||||
// The section's right-click menu adds the copied paint (`fill.paste` / `stroke.paste`).
|
||||
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
|
||||
let (clicked, header) = widgets::section_header_resp(
|
||||
ui,
|
||||
title,
|
||||
&[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })],
|
||||
empty,
|
||||
) {
|
||||
);
|
||||
if let Some(i) = clicked {
|
||||
if i == 1 {
|
||||
// The new paint goes on top; open its picker. A mixed selection has no one index to edit.
|
||||
if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some()
|
||||
@@ -1115,6 +1127,12 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
|
||||
style_picker::open(app, ui, &ids, slot);
|
||||
}
|
||||
}
|
||||
context_popup(&header).show(|ui| {
|
||||
if app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills) && menu_item(ui, paste, "") {
|
||||
app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
style_picker::chip(app, ui, doc, &ids, slot);
|
||||
match &list {
|
||||
None => widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary),
|
||||
@@ -1536,7 +1554,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ids = ids.to_vec();
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
row_rect(ui, 32.0, |ui| {
|
||||
let r = row_rect(ui, 32.0, |ui| {
|
||||
let w = ui.available_width() - 56.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
|
||||
@@ -1619,7 +1637,21 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
||||
None => app.ui.picker = None,
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") };
|
||||
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
|
||||
let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills);
|
||||
row_context_menu(ui, r, Id::new(("paint_menu", fills, i))).show(|ui| {
|
||||
if menu_item(ui, copy, "") {
|
||||
app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i}));
|
||||
ui.close();
|
||||
}
|
||||
if can_paste && menu_item(ui, paste, "") {
|
||||
app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids, "index": i}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
/// A paint row's label (also what a drag of it carries): its colour, gradient kind or "Image".
|
||||
@@ -1637,13 +1669,20 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
let list = common(doc, sel, |n| n.effects.clone());
|
||||
let empty = list.as_ref().is_none_or(|l| l.is_empty());
|
||||
if let Some(i) = widgets::section_header(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty) {
|
||||
let (clicked, header) = widgets::section_header_resp(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty);
|
||||
if let Some(i) = clicked {
|
||||
if i == 1 {
|
||||
app.run("effect.add", json!({"ids": ids, "type": "dropShadow"}));
|
||||
} else {
|
||||
style_picker::open(app, ui, &ids, StyleSlot::Effect);
|
||||
}
|
||||
}
|
||||
context_popup(&header).show(|ui| {
|
||||
if app.session.clipboard.effect.is_some() && menu_item(ui, tr("Paste effect"), "") {
|
||||
app.run("effect.paste", json!({"ids": ids}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect);
|
||||
let Some(list) = list else { return };
|
||||
let mut drag = ListDrag::new(ui, "effects");
|
||||
@@ -1678,6 +1717,17 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
removed = Some(i);
|
||||
}
|
||||
});
|
||||
let can_paste = app.session.clipboard.effect.is_some();
|
||||
row_context_menu(ui, header, Id::new(("effect_menu", i))).show(|ui| {
|
||||
if menu_item(ui, tr("Copy effect"), "") {
|
||||
app.run("effect.copy", json!({"ids": ids, "index": i}));
|
||||
ui.close();
|
||||
}
|
||||
if can_paste && menu_item(ui, tr("Paste effect"), "") {
|
||||
app.run("effect.paste", json!({"ids": ids, "index": i}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if open {
|
||||
effect_settings(app, ui, &ids, i, e);
|
||||
}
|
||||
|
||||
@@ -826,9 +826,19 @@ pub fn disclosure(ui: &mut Ui, label: &str, open: bool) -> bool {
|
||||
/// A section header ("Fill", "Stroke"…) with optional right-side icon buttons; returns which
|
||||
/// button was clicked. `dim` greys the title (empty sections).
|
||||
pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> Option<usize> {
|
||||
section_header_resp(ui, title, buttons, dim).0
|
||||
}
|
||||
|
||||
/// [`section_header`], with the header's own response for a right-click menu.
|
||||
pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> (Option<usize>, egui::Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click());
|
||||
// Only an empty section's header acts as its `+` button; elsewhere it is a right-click target
|
||||
// alone, so it keeps the normal cursor.
|
||||
if resp.hovered() && !dim {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
|
||||
}
|
||||
ui.painter().text(
|
||||
pos2(rect.min.x + PAD, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
@@ -850,7 +860,11 @@ pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: b
|
||||
}
|
||||
x -= 24.0;
|
||||
}
|
||||
out
|
||||
// An empty section's whole header adds its item, not only its `+` button.
|
||||
if out.is_none() && dim && resp.clicked() {
|
||||
out = buttons.iter().position(|(icon, _)| *icon == Icon::Plus);
|
||||
}
|
||||
(out, resp)
|
||||
}
|
||||
|
||||
/// An icon button at `rect` (hover highlight, tooltip); true when clicked.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Parity checklist
|
||||
|
||||
> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand.
|
||||
> **Generated:** 2026-10-21 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine).
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (575 commands in the engine).
|
||||
|
||||
**212 / 212 menu items (100.0%)**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user