mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
feat: 属性与样式复制粘贴,含图片/图案跨文件处理与多语言
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1063,6 +1063,7 @@ status = "draft — needs native review"
|
||||
"Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত"
|
||||
"Progressive" = "প্রোগ্রেসিভ"
|
||||
"Properties" = "প্রপার্টিজ"
|
||||
"Properties copied" = "প্রপার্টিজ কপি করা হয়েছে"
|
||||
"Prototype" = "প্রোটোটাইপ"
|
||||
"Prototype preview" = "প্রোটোটাইপ প্রিভিউ"
|
||||
"Prototype settings" = "প্রোটোটাইপ সেটিংস"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1054,6 +1054,7 @@ status = "draft — needs native review"
|
||||
"Previous ready for dev" = "前の開発準備完了"
|
||||
"Progressive" = "プログレッシブ"
|
||||
"Properties" = "プロパティ"
|
||||
"Properties copied" = "プロパティをコピーしました"
|
||||
"Prototype" = "プロトタイプ"
|
||||
"Prototype preview" = "プロトタイプのプレビュー"
|
||||
"Prototype settings" = "プロトタイプの設定"
|
||||
|
||||
@@ -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" = "პროტოტიპის პარამეტრები"
|
||||
|
||||
@@ -1005,6 +1005,7 @@ status = "draft — needs native review"
|
||||
"Previous ready for dev" = "이전 개발 준비 완료"
|
||||
"Progressive" = "점진적"
|
||||
"Properties" = "속성"
|
||||
"Properties copied" = "속성 복사됨"
|
||||
"Prototype" = "프로토타입"
|
||||
"Prototype preview" = "프로토타입 미리 보기"
|
||||
"Prototype settings" = "프로토타입 설정"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1069,6 +1069,7 @@ name = "Русский"
|
||||
"Previous ready for dev" = "Предыдущий готовый к разработке"
|
||||
"Progressive" = "Прогрессивный"
|
||||
"Properties" = "Свойства"
|
||||
"Properties copied" = "Свойства скопированы"
|
||||
"Prototype" = "Прототип"
|
||||
"Prototype preview" = "Предпросмотр прототипа"
|
||||
"Prototype settings" = "Настройки прототипа"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ı"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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, ¶ms) {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user