feat: 属性与样式复制粘贴,含图片/图案跨文件处理与多语言

This commit is contained in:
Nicek
2026-10-29 09:33:20 +08:00
committed by trae-solo-agent
parent 2af5e232d5
commit bb495bcf70
28 changed files with 295 additions and 42 deletions
+15 -2
View File
@@ -112,6 +112,8 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
let props = s.clipboard.props.take();
let paint = s.clipboard.paint.take();
let stroke = s.clipboard.stroke.take();
let paint_images = std::mem::take(&mut s.clipboard.paint_images);
let paint_source_doc = s.clipboard.paint_source_doc;
let effect = s.clipboard.effect.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
@@ -121,6 +123,8 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
props,
paint,
stroke,
paint_images,
paint_source_doc,
effect,
bounds,
source_doc: 0,
@@ -294,9 +298,18 @@ pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let mut v = json!({
"fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align,
"effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii,
"cornerSmoothing": n.corners.smoothing,
"strokeSides": n.stroke.sides, "startCap": n.stroke.cap_start, "endCap": n.stroke.cap_end,
"strokeJoin": n.stroke.join, "miterLimit": n.stroke.miter_limit, "dashes": n.stroke.dashes,
"widthProfile": super::super::props::width_points_json(n.stroke.width_profile.as_ref()), "brush": n.stroke.brush,
"effects": n.effects, "opacity": n.opacity, "blend": n.blend,
});
// A vector rounds each anchor on its own (`vector.edit.cornerRadius` keeps the radii in the
// network's vertices, its layer corners stay empty): there is no style radius to carry, and
// pasting the empty one would flatten every anchor's radius.
if !matches!(n.kind, NodeKind::Vector { .. }) {
v["cornerRadius"] = json!(n.corners.radii);
v["cornerSmoothing"] = json!(n.corners.smoothing);
}
if let NodeKind::Text(t) = &n.kind {
v["fontFamily"] = json!(t.style.font_family);
v["fontStyle"] = json!(t.style.font_style);
+22 -1
View File
@@ -346,6 +346,16 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
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()))
};
// An image paint names its data by key: take the data along, so a paste into another file has
// it too. A pattern names a layer in this file, so the file is recorded with the copy.
let mut images = std::collections::BTreeMap::new();
if let figcraft_color::PaintKind::Image(img) = &paint.kind
&& let Some(d) = s.doc()?.images.get(&img.image)
{
images.insert(img.image.clone(), d.clone());
}
s.clipboard.paint_images = images;
s.clipboard.paint_source_doc = s.ds()?.uid;
s.clipboard.paint = Some((fills, paint.clone()));
s.clipboard.stroke = stroke;
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
@@ -364,13 +374,24 @@ pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Val
}
let ids = req_targets(cmd, s, p)?;
// A copied pattern pointing at a target or a layer inside it would make the layer pattern
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern.
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. Its node id
// only names something in the file it was copied from, where it may be an unrelated layer.
if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind {
if s.ds()?.uid != s.clipboard.paint_source_doc {
return Err(bad(cmd, "a pattern can't be pasted into another file"));
}
let doc = s.doc()?;
if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) {
return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it"));
}
}
// An image paint names its data by key; a copy from another file brings the data with it.
if let figcraft_color::PaintKind::Image(img) = &paint.kind {
let key = img.image.clone();
if let Some(d) = s.clipboard.paint_images.get(&key).cloned() {
s.doc_mut()?.images.entry(key).or_insert(d);
}
}
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) => {
+4
View File
@@ -164,6 +164,10 @@ pub struct Clipboard {
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>,
/// The image data the copied paint names, carried so a paste into another file has it.
pub paint_images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
/// The file the copied paint came from: a pattern's node id only means anything there.
pub paint_source_doc: u64,
/// A copied effect row (`effect.copy`).
pub effect: Option<figcraft_doc::Effect>,
/// The page-space bounds of the copied selection.
+30
View File
@@ -103,6 +103,36 @@ fn pasting_design_layers_keeps_a_copied_row() {
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
}
#[test]
fn a_copied_image_fill_brings_its_image_to_another_file() {
let mut s = Session::new();
let a = rect(&mut s);
ex(&mut s, "fill.add", json!({"ids": [a], "paint": {"type": "image", "image": "img-key"}}));
let data = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 2, height: 2 };
s.doc_mut().unwrap().images.insert("img-key".into(), data.clone());
ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
// Another file has no image under that key: the copy brings the data with it.
ex(&mut s, "file.new", json!({}));
let b = rect(&mut s);
assert!(s.doc().unwrap().images.get("img-key").is_none());
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
assert_eq!(s.doc().unwrap().images.get("img-key"), Some(&data));
assert!(matches!(node(&s, b).fills[0].kind, figcraft_color::PaintKind::Image(_)));
}
#[test]
fn a_copied_pattern_does_not_paste_into_another_file() {
let mut s = Session::new();
let source = rect(&mut s);
let patterned = rect(&mut s);
ex(&mut s, "fill.pattern", json!({"ids": [patterned], "source": source}));
ex(&mut s, "fill.copy", json!({"id": patterned}));
// Its node id means nothing in another file, where it may name an unrelated layer.
ex(&mut s, "file.new", json!({}));
let b = rect(&mut s);
assert!(s.execute("fill.paste", &json!({"ids": [b], "index": 0})).is_err(), "a pattern stays in its file");
}
#[test]
fn a_copied_pattern_cannot_paste_into_a_layer_it_draws() {
let mut s = Session::new();
+1
View File
@@ -1063,6 +1063,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত"
"Progressive" = "প্রোগ্রেসিভ"
"Properties" = "প্রপার্টিজ"
"Properties copied" = "প্রপার্টিজ কপি করা হয়েছে"
"Prototype" = "প্রোটোটাইপ"
"Prototype preview" = "প্রোটোটাইপ প্রিভিউ"
"Prototype settings" = "প্রোটোটাইপ সেটিংস"
+1
View File
@@ -1042,6 +1042,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Předchozí připravené pro vývoj"
"Progressive" = "Postupné"
"Properties" = "Vlastnosti"
"Properties copied" = "Vlastnosti zkopírovány"
"Prototype" = "Prototyp"
"Prototype preview" = "Náhled prototypu"
"Prototype settings" = "Nastavení prototypu"
+1
View File
@@ -1011,6 +1011,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Vorheriges bereit für Entwicklung"
"Progressive" = "Progressiv"
"Properties" = "Eigenschaften"
"Properties copied" = "Eigenschaften kopiert"
"Prototype" = "Prototyp"
"Prototype preview" = "Prototyp-Vorschau"
"Prototype settings" = "Prototyp-Einstellungen"
+1
View File
@@ -1095,6 +1095,7 @@ name = "English"
"Previous ready for dev" = "Previous ready for dev"
"Progressive" = "Progressive"
"Properties" = "Properties"
"Properties copied" = "Properties copied"
"Prototype" = "Prototype"
"Prototype preview" = "Prototype preview"
"Prototype settings" = "Prototype settings"
+1
View File
@@ -1092,6 +1092,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Anterior listo para desarrollo"
"Progressive" = "Progresivo"
"Properties" = "Propiedades"
"Properties copied" = "Propiedades copiadas"
"Prototype" = "Prototipo"
"Prototype preview" = "Vista previa del prototipo"
"Prototype settings" = "Ajustes del prototipo"
+1
View File
@@ -1067,6 +1067,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Edellinen kehitysvalmis"
"Progressive" = "Asteittainen"
"Properties" = "Ominaisuudet"
"Properties copied" = "Ominaisuudet kopioitu"
"Prototype" = "Prototyyppi"
"Prototype preview" = "Prototyypin esikatselu"
"Prototype settings" = "Prototyypin asetukset"
+1
View File
@@ -1066,6 +1066,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Prêt pour le dev précédent"
"Progressive" = "Progressif"
"Properties" = "Propriétés"
"Properties copied" = "Propriétés copiées"
"Prototype" = "Prototype"
"Prototype preview" = "Aperçu du prototype"
"Prototype settings" = "Réglages du prototype"
+1
View File
@@ -1049,6 +1049,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Versi siap dev sebelumnya"
"Progressive" = "Progresif"
"Properties" = "Properti"
"Properties copied" = "Properti disalin"
"Prototype" = "Prototype"
"Prototype preview" = "Pratinjau prototype"
"Prototype settings" = "Pengaturan prototype"
+1
View File
@@ -1054,6 +1054,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "前の開発準備完了"
"Progressive" = "プログレッシブ"
"Properties" = "プロパティ"
"Properties copied" = "プロパティをコピーしました"
"Prototype" = "プロトタイプ"
"Prototype preview" = "プロトタイプのプレビュー"
"Prototype settings" = "プロトタイプの設定"
+1
View File
@@ -1065,6 +1065,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Previous ready for dev" = "წინა მზად დეველოპერისთვის"
"Progressive" = "პროგრესული"
"Properties" = "თვისებები"
"Properties copied" = "თვისებები დაკოპირდა"
"Prototype" = "პროტოტიპი"
"Prototype preview" = "პროტოტიპის წინასწარი ნახვა"
"Prototype settings" = "პროტოტიპის პარამეტრები"
+1
View File
@@ -1005,6 +1005,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "이전 개발 준비 완료"
"Progressive" = "점진적"
"Properties" = "속성"
"Properties copied" = "속성 복사됨"
"Prototype" = "프로토타입"
"Prototype preview" = "프로토타입 미리 보기"
"Prototype settings" = "프로토타입 설정"
+1
View File
@@ -1067,6 +1067,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Vorige klaar voor developers"
"Progressive" = "Progressive"
"Properties" = "Eigenschappen"
"Properties copied" = "Eigenschappen gekopieerd"
"Prototype" = "Prototype"
"Prototype preview" = "Prototypevoorvertoning"
"Prototype settings" = "Prototype-instellingen"
+1
View File
@@ -1069,6 +1069,7 @@ name = "Polski"
"Previous ready for dev" = "Poprzedni gotowy do wdrożenia"
"Progressive" = "Progresywny"
"Properties" = "Właściwości"
"Properties copied" = "Właściwości skopiowane"
"Prototype" = "Prototyp"
"Prototype preview" = "Podgląd prototypu"
"Prototype settings" = "Ustawienia prototypu"
+1
View File
@@ -1004,6 +1004,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "Anterior pronto para dev"
"Progressive" = "Progressivo"
"Properties" = "Propriedades"
"Properties copied" = "Propriedades copiadas"
"Prototype" = "Protótipo"
"Prototype preview" = "Visualização do protótipo"
"Prototype settings" = "Configurações do protótipo"
+1
View File
@@ -1069,6 +1069,7 @@ name = "Русский"
"Previous ready for dev" = "Предыдущий готовый к разработке"
"Progressive" = "Прогрессивный"
"Properties" = "Свойства"
"Properties copied" = "Свойства скопированы"
"Prototype" = "Прототип"
"Prototype preview" = "Предпросмотр прототипа"
"Prototype settings" = "Настройки прототипа"
+1
View File
@@ -1072,6 +1072,7 @@ name = "Svenska"
"Previous ready for dev" = "Föregående redo för utveckling"
"Progressive" = "Progressiv"
"Properties" = "Egenskaper"
"Properties copied" = "Egenskaper kopierade"
"Prototype" = "Prototyp"
"Prototype preview" = "Förhandsgranskning av prototyp"
"Prototype settings" = "Prototypinställningar"
+1
View File
@@ -1046,6 +1046,7 @@ name = "Türkçe"
"Previous ready for dev" = "Önceki geliştirmeye hazır"
"Progressive" = "Aşamalı"
"Properties" = "Özellikler"
"Properties copied" = "Özellikler kopyalandı"
"Prototype" = "Prototip"
"Prototype preview" = "Prototip önizlemesi"
"Prototype settings" = "Prototip ayarları"
+1
View File
@@ -1013,6 +1013,7 @@ status = "draft — needs native review"
"Previous ready for dev" = "上一个可供开发"
"Progressive" = "渐进"
"Properties" = "属性"
"Properties copied" = "属性已复制"
"Prototype" = "原型"
"Prototype preview" = "原型预览"
"Prototype settings" = "原型设置"
@@ -280,8 +280,19 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered:
Action::Reorder { .. } => CursorIcon::Grabbing,
});
if !down {
// A plain press on a reorder ring that didn't become a drag is a click on that layer:
// the selection narrows to it, as a click on the canvas would. With a modifier held
// the ring keeps Figma's meaning (Shift/Alt/Cmd on a selected layer), so it does nothing.
let plain = !ctx.input(|i| i.modifiers.shift || i.modifiers.alt || i.modifiers.command || i.modifiers.ctrl);
let clicked = match &drag.action {
Action::Reorder { source, .. } if plain && !drag.moved => Some(*source),
_ => None,
};
let _ = app.session.commit_interaction();
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
if let Some(id) = clicked {
app.session.set_selection(vec![id]);
}
} else {
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
}
+3 -2
View File
@@ -1,4 +1,4 @@
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_rect};
use super::stroke::stroke_settings;
use super::{
Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, PAD, Paint,
@@ -132,7 +132,8 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
let label = blend.map(|b| tr(b.label())).unwrap_or(tr("Mixed"));
let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend));
menu_popup(&resp).width(200.0).show(|ui| {
if let Some(b) = blend_menu(ui, true) {
let ctx = ui.ctx().clone();
if let Some(b) = blend_menu(ui, true, |b| preview_blend(app, &ctx, &ids, b)) {
app.set_props(&ids, json!({"blend": b.label()}));
}
});
+19 -1
View File
@@ -228,6 +228,19 @@ fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() {
assert_eq!(xy(&u, ids[0]), (180., 200.));
}
#[test]
fn a_click_on_one_of_several_selected_layers_selects_it() {
let (mut u, ids) = scene(false);
// The reorder ring sits at the layer's centre and takes the press; a click (no drag) on it is
// a click on the layer, which then has the selection to itself.
let p = u.app.to_screen(125., 130.);
u.frame(vec![Event::PointerMoved(p)]);
u.button(p, true);
u.button(p, false);
u.frame(vec![]);
assert_eq!(u.app.session.selection(), vec![ids[0]], "the click selects the layer whose ring it hit");
}
#[test]
fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() {
for action in [0, 1, 2] {
@@ -252,7 +265,12 @@ fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() {
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap(), &before);
assert_eq!(u.app.session.undo_depth().0, history);
assert_eq!(u.app.session.selection(), ids);
if action == 0 {
// The press on a ring that doesn't drag is a click on that layer.
assert_eq!(u.app.session.selection(), vec![ids[0]], "a click on a ring selects its layer");
} else {
assert_eq!(u.app.session.selection(), ids);
}
assert!(u.app.session.ds().unwrap().interaction.is_none());
}
}
+6 -1
View File
@@ -179,7 +179,12 @@ pub(crate) fn handle_input(
let now = ctx.input(|i| i.time);
for ev in &events {
match ev {
Event::Copy => crate::menus::invoke(app, "edit.copy"),
// egui-winit sends Cmd/Ctrl+C as a copy event whatever Alt does, so Copy properties
// (Alt+Cmd/Ctrl+C) arrives here and never as a key.
Event::Copy => {
let props = ctx.input(|i| i.modifiers.alt);
crate::menus::invoke(app, if props { "edit.copyProperties" } else { "edit.copy" });
}
Event::Cut => cut(app),
Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None),
Event::Key { key, physical_key, pressed: true, modifiers, repeat }
+119
View File
@@ -0,0 +1,119 @@
//! Copying a layer's style with Cmd+Alt+C / Ctrl+Alt+C and pasting it onto other layers with
//! Cmd+Alt+V / Ctrl+Alt+V: fills, strokes, corners, effects, opacity and blend mode.
use egui::{Event, Key, Modifiers};
use figcraft_doc::{NodeId, NodeKind};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// The copy and paste keys of the platform the tests run on.
fn chord() -> Modifiers {
if cfg!(target_os = "macos") {
Modifiers { alt: true, command: true, ..Default::default() }
} else {
Modifiers { alt: true, ctrl: true, command: true, ..Default::default() }
}
}
/// Cmd/Ctrl+Alt+V as the desktop shell delivers it: the press is swallowed, the release arrives.
fn release_v(u: &mut Ui) {
let m = chord();
u.frame_with(vec![Event::Key { key: Key::V, physical_key: Some(Key::V), pressed: false, repeat: false, modifiers: m }], m);
}
/// Two rectangles, the first with a full style and selected.
fn scene() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let styled = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20})).unwrap());
let plain = id(s.execute("node.create", &json!({"type": "rectangle", "x": 100, "y": 0, "width": 40, "height": 20})).unwrap());
s.execute(
"node.set",
&json!({"id": styled, "props": {
"fills": [{"type": "solid", "color": "#ff0000"}],
"strokes": [{"type": "solid", "color": "#0000ff"}],
"strokeWeight": 3,
"strokeAlign": "outside",
"strokeSides": [1, 2, 3, 4],
"startCap": "round",
"endCap": "square",
"strokeJoin": "bevel",
"miterLimit": 7,
"dashes": [4, 2],
"widthProfile": [[0, 0.5], [1, 1]],
"brush": "taper",
"cornerRadius": 6,
"cornerSmoothing": 0.6,
"effects": [{"type": "dropShadow", "offset": [2, 4], "radius": 5, "spread": 1}],
"opacity": 0.5,
"blend": "multiply",
}}),
)
.unwrap();
s.execute("select.set", &json!({"ids": [styled]})).unwrap();
(Ui::new(s), styled, plain)
}
#[test]
fn the_copy_chord_arrives_as_a_copy_event_and_takes_properties_not_layers() {
let (mut u, _styled, _plain) = scene();
// egui-winit sends Cmd/Ctrl+C as a copy event whatever Alt does: the key never reaches the
// keymap, and Alt is what tells Copy properties from Copy.
u.frame_with(vec![Event::Copy], chord());
assert!(u.app.session.clipboard.props.is_some(), "Copy properties ran from the copy event");
assert!(u.app.session.clipboard.nodes.is_empty(), "the layers were not copied");
assert!(u.app.ui.toast.is_some(), "the copy shows a confirmation");
}
#[test]
fn the_style_keys_copy_and_paste_every_style_property() {
let (mut u, styled, plain) = scene();
u.frame_with(vec![Event::Copy], chord());
u.app.session.execute("select.set", &json!({"ids": [plain]})).unwrap();
release_v(&mut u);
let doc = u.app.session.doc().unwrap();
let (a, b) = (doc.node(styled).unwrap(), doc.node(plain).unwrap());
assert_eq!(b.fills, a.fills, "fills");
assert_eq!(b.stroke, a.stroke, "strokes: paints, weight, align, sides, caps, join, dashes, width profile, brush");
assert_eq!(b.corners, a.corners, "corner radii and smoothing");
assert_eq!(b.effects, a.effects, "effects");
assert_eq!(b.opacity, a.opacity, "layer opacity");
assert_eq!(b.blend, a.blend, "blend mode");
}
#[test]
fn a_vectors_anchor_radii_are_not_part_of_the_copied_style() {
// Radii set on anchors in edit mode live in the network's vertices, not in the layer's corners:
// copying such a vector's style and pasting it back must leave every anchor's radius as it was.
let mut s = Session::new();
let v = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 40})).unwrap());
s.execute("vector.edit.begin", &json!({"id": v})).unwrap();
s.execute("vector.edit.cornerRadius", &json!({"radius": 6, "vertices": [0, 1]})).unwrap();
s.execute("vector.edit.end", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [v]})).unwrap();
let radii = |s: &Session| match &s.doc().unwrap().node(v).unwrap().kind {
NodeKind::Vector { network } => network.vertices.iter().map(|p| p.radius).collect::<Vec<_>>(),
_ => panic!("the layer was converted to a vector"),
};
let before = radii(&s);
assert_eq!(&before[..2], &[6.0, 6.0], "two anchors are rounded");
let mut u = Ui::new(s);
u.frame_with(vec![Event::Copy], chord());
release_v(&mut u);
assert_eq!(radii(&u.app.session), before, "the paste left every anchor's radius alone");
}
#[test]
fn pasting_a_style_leaves_the_geometry_alone() {
let (mut u, _styled, plain) = scene();
u.frame_with(vec![Event::Copy], chord());
u.app.session.execute("select.set", &json!({"ids": [plain]})).unwrap();
release_v(&mut u);
let b = u.app.session.doc().unwrap().world_bounds(plain).unwrap();
assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 0.0, 40.0, 20.0), "the layer keeps its place and size");
}
+48 -35
View File
@@ -3,7 +3,7 @@
use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::{
Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint,
BlendMode, Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint,
PatternTile,
};
use figcraft_doc::{NodeId, NodeKind};
@@ -118,6 +118,40 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
}
}
/// The command and its params that write `paint` to `target`, when the target can take it.
fn paint_command(app: &FigApp, target: &PickerTarget, paint: &Paint) -> Option<(&'static str, serde_json::Value)> {
Some(match target {
PickerTarget::PluginField { .. } => return None,
PickerTarget::SelectionColor { targets, .. } => ("selection.setPaint", json!({"targets": targets, "paint": paint})),
PickerTarget::PageBackground => {
let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE);
("page.setBackground", json!({"color": c.to_css_hex()}))
}
PickerTarget::Paint { fills, index, ids } => {
(if *fills { "fill.set" } else { "stroke.set" }, json!({"ids": ids, "index": index, "paint": paint}))
}
PickerTarget::Effect { ids, index, secondary } => {
let mut e = ids.first().and_then(|i| app.session.doc().ok()?.node(*i)?.effects.get(*index).cloned())?;
if let Some(c) = e.color_mut(*secondary) {
*c = paint.solid_color().unwrap_or(Color::BLACK);
}
("effect.set", json!({"ids": ids, "index": index, "effect": e}))
}
PickerTarget::TextRange { id, start, end } => ("text.style", json!({"id": id, "start": start, "end": end, "textFills": [paint]})),
PickerTarget::Variable { id, mode } => {
let c = paint.solid_color().unwrap_or(Color::BLACK);
("variable.set", json!({"variable": id, "mode": mode, "value": c.to_css_hex()}))
}
PickerTarget::Style { id, index } => {
let mut paints = app.session.doc().ok().and_then(|d| style_paints(d, id))?;
if let Some(slot) = paints.get_mut(*index) {
*slot = paint.clone();
}
("style.update", json!({"style": id, "value": paints}))
}
})
}
/// Write `paint` to the target. `live` edits are part of an ongoing drag.
fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
if let PickerTarget::PluginField { plugin, control, .. } = target {
@@ -132,39 +166,8 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
if live {
let _ = app.session.begin_interaction("Color");
}
let r = match target {
PickerTarget::SelectionColor { targets, .. } => app.session.execute("selection.setPaint", &json!({"targets": targets, "paint": paint})),
PickerTarget::PageBackground => {
let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE);
app.session.execute("page.setBackground", &json!({"color": c.to_css_hex()}))
}
PickerTarget::Paint { fills, index, ids } => {
app.session.execute(if *fills { "fill.set" } else { "stroke.set" }, &json!({"ids": ids, "index": index, "paint": paint}))
}
PickerTarget::Effect { ids, index, secondary } => {
let Some(mut e) = ids.first().and_then(|i| app.session.doc().ok()?.node(*i)?.effects.get(*index).cloned()) else { return };
if let Some(c) = e.color_mut(*secondary) {
*c = paint.solid_color().unwrap_or(Color::BLACK);
}
app.session.execute("effect.set", &json!({"ids": ids, "index": index, "effect": e}))
}
PickerTarget::TextRange { id, start, end } => {
app.session.execute("text.style", &json!({"id": id, "start": start, "end": end, "textFills": [paint]}))
}
PickerTarget::Variable { id, mode } => {
let c = paint.solid_color().unwrap_or(Color::BLACK);
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()}))
}
PickerTarget::PluginField { .. } => return,
PickerTarget::Style { id, index } => {
let Some(mut paints) = app.session.doc().ok().and_then(|d| style_paints(d, id)) else { return };
if let Some(slot) = paints.get_mut(*index) {
*slot = paint.clone();
}
app.session.execute("style.update", &json!({"style": id, "value": paints}))
}
};
if let Err(e) = r {
let Some((cmd, params)) = paint_command(app, target, paint) else { return };
if let Err(e) = app.session.execute(cmd, &params) {
app.toast(e.to_string());
}
// Drag previews (`live`) are not choices: a drag records only the colour it ends on. Not "an
@@ -174,6 +177,15 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
}
}
/// Show the target's paint with `blend` on the canvas while its blend mode row is hovered.
pub(crate) fn preview_blend(app: &mut FigApp, ctx: &egui::Context, target: &PickerTarget, paint: &Paint, blend: BlendMode) {
let mut p = paint.clone();
p.blend = blend;
if let Some((cmd, params)) = paint_command(app, target, &p) {
app.preview_text(ctx, cmd, params);
}
}
fn remember_recent(app: &mut FigApp, c: Color) {
// By 8-bit value: a document stores colours rounded to 8 bits, so the colour picked and the one read
// back differ by a fraction and would show as two identical swatches.
@@ -610,7 +622,8 @@ fn blend_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut P
ui.add_space(16.0);
let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
crate::right::menu_popup(&resp).width(200.0).show(|ui| {
if let Some(b) = crate::right::blend_menu(ui, false) {
let ctx = ui.ctx().clone();
if let Some(b) = crate::right::blend_menu(ui, false, |b| preview_blend(app, &ctx, target, paint, b)) {
paint.blend = b;
write(app, target, paint, false);
}