diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index eb585551..8ee5c3aa 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -254,6 +254,9 @@ impl Services for Desktop { fn clipboard_get_text(&mut self) -> Option { self.clipboard.as_mut()?.get_text().ok() } + fn clipboard_get_html(&mut self) -> Option { + self.clipboard.as_mut()?.get().html().ok() + } fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) { if let Some(c) = self.clipboard.as_mut() { let _ = c.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(rgba) }); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..21f09569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,6 +25,13 @@ pub fn specs() -> Vec { spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), + spec!( + "edit.pasteDesignLayers", + "Paste design layers", + "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + true, + paste_design_layers + ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), spec!("edit.copyProperties", "Copy properties", "{id?}", false, copy_props), @@ -498,7 +505,20 @@ fn bump_name(n: &str) -> String { /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let doc = &ds.doc; + let (nodes, images, bounds) = copied(&ds.doc, ids); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { let ids = z_sorted(doc, &roots(doc, ids)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); @@ -521,16 +541,59 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { nodes.push((*i, sub)); } let bounds = doc.bounds_of(&ids); - let uid = ds.uid; - let source = Some(ds.doc.clone()); - let source_path = ds.path.clone(); - s.clipboard.source = source; - s.clipboard.source_path = source_path; - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.source_doc = uid; - Ok(()) + (nodes, images, bounds) +} + +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; +pub const CLIP_CLOSE: &str = "(/figma)"; +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; + +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { + use base64::Engine as _; + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + 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 }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) } fn copy(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 12e93f16..4254f435 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -523,10 +523,8 @@ fn load_with( Ok((doc.into(), false)) } _ => { - if let Some(r) = figcraft_format::fig::try_import(bytes, &file_stem(name_hint)) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; @@ -542,6 +540,15 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + /// Link imported instances to their mains (layer sources, overrides found by comparing values), /// innermost first so outer instances settle last. fn link_instances(doc: &mut Document) { diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs new file mode 100644 index 00000000..89de80ef --- /dev/null +++ b/crates/engine/src/design_paste_tests.rs @@ -0,0 +1,108 @@ +//! Paste of layers copied from another design app: the clipboard's design data (a `.fig` canvas in +//! its HTML) read through the `.fig` import and pasted as Paste pastes our own layers. + +use base64::Engine as _; +use serde_json::json; + +use super::*; +use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; + +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +fn id(v: &Value) -> NodeId { + params::id_of(&v["id"]).unwrap() +} + +fn copied_file() -> Vec { + let mut s = Session::new(); + let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()); + s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 40, "height": 20, "parent": frame, "name": "Badge"})).unwrap(); + s.execute("node.create", &json!({"type": "ellipse", "x": 300, "y": 0, "width": 50, "height": 50, "name": "Dot"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + B64.decode(r["data"].as_str().unwrap()).unwrap() +} + +fn clipboard_html(buffer: &[u8]) -> String { + let mut wrapped = B64.encode(buffer); + wrapped.insert(8, '\n'); + format!(r#""#) +} + +fn names(s: &Session, ids: &[NodeId]) -> Vec { + let doc = s.doc().unwrap(); + ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +#[test] +fn buffer_is_found_in_the_html() { + let file = copied_file(); + assert_eq!(design_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(design_clipboard_buffer("

plain

"), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}not base64!{CLIP_CLOSE}")), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{}{CLIP_CLOSE}", B64.encode(b"hello"))), None); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}ZmlnLQ")), None); + // Web text that only mentions the markers holds no design data. + assert_eq!(design_clipboard_buffer(&format!("

see {CLIP_OPEN} and {CLIP_CLOSE} in the docs

")), None); +} + +#[test] +fn oversized_design_data_is_refused_before_decoding() { + // Just past the cap (256 MB of data, as base64), made of valid base64 characters. + let big = "A".repeat((256 * 1024 * 1024usize).div_ceil(3) * 4 + 4); + assert_eq!(design_clipboard_buffer(&format!("{CLIP_OPEN}{big}{CLIP_CLOSE}")), None); + let mut s = Session::new(); + let e = s.execute("edit.pasteDesignLayers", &json!({ "data": big })).unwrap_err(); + assert!(e.to_string().contains("256 MB"), "{e}"); + assert!(s.clipboard.nodes.is_empty()); +} + +#[test] +fn layers_paste_into_the_page_and_undo() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); + assert_eq!(s.selection(), ids); + let doc = s.doc().unwrap(); + assert!(ids.iter().all(|i| doc.node(*i).unwrap().parent == Some(page))); + let card = doc.node(ids[0]).unwrap(); + assert_eq!(names(&s, &card.children), ["Badge"]); + assert_eq!((card.width, card.height), (200.0, 100.0)); + assert!(s.undo().unwrap()); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn a_second_paste_repeats_the_copied_layers() { + let mut s = Session::new(); + // `data` may be wrapped and unpadded, like the HTML's base64. + let mut data = B64.encode(copied_file()).trim_end_matches('=').to_string(); + data.insert(8, '\n'); + s.execute("edit.pasteDesignLayers", &json!({ "data": data })).unwrap(); + let r = s.execute("edit.paste", &json!({})).unwrap(); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| params::id_of(v).unwrap()).collect(); + assert_eq!(names(&s, &ids), ["Card", "Dot"]); +} + +#[test] +fn pastes_into_the_selected_frame() { + let mut s = Session::new(); + let host = id(&s.execute("frame.create", &json!({"x": 1000, "y": 0, "width": 800, "height": 600})).unwrap()); + s.execute("select.set", &json!({"ids": [host]})).unwrap(); + let r = s.execute("edit.pasteDesignLayers", &json!({"html": clipboard_html(&copied_file())})).unwrap(); + let first = params::id_of(&r["ids"][0]).unwrap(); + assert_eq!(s.doc().unwrap().node(first).unwrap().parent, Some(host)); +} + +#[test] +fn other_content_is_refused_without_changes() { + let mut s = Session::new(); + let before = s.doc().unwrap().nodes().count(); + assert!(s.execute("edit.pasteDesignLayers", &json!({})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteDesignLayers", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + assert_eq!(s.doc().unwrap().nodes().count(), before); + assert!(s.clipboard.nodes.is_empty()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f4274aec..e5fc41f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1520,6 +1520,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 61c84e01..eb698507 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -940,6 +940,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Vložte odkaz na týmovou knihovnu (odkaz pro prohlížení)" "Paste here" = "Vložit sem" "Paste to replace" = "Vložit a nahradit" +"Pasted design layers; {n} kinds of content were left out" = { one = "Vloženy vrstvy návrhu; vynechán {n} druh obsahu", few = "Vloženy vrstvy návrhu; vynechány {n} druhy obsahu", other = "Vloženy vrstvy návrhu; vynecháno {n} druhů obsahu" } "Pattern" = "Vzor" "Pattern alignment" = "Zarovnání vzoru" "Pattern scale" = "Měřítko vzoru" @@ -1784,6 +1785,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplikovat" "edit.findReplace" = "Najít a nahradit" "edit.paste" = "Vložit" +"edit.pasteDesignLayers" = "Vložit vrstvy návrhu" "edit.pasteOver" = "Vložit přes výběr" "edit.pasteProperties" = "Vložit vlastnosti" "edit.pasteReplace" = "Vložit a nahradit" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 1516699b..857c2eb5 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -909,6 +909,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Link einer Teambibliothek einfügen (ein Ansichtslink)" "Paste here" = "Hier einfügen" "Paste to replace" = "Zum Ersetzen einfügen" +"Pasted design layers; {n} kinds of content were left out" = { one = "Designebenen eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Designebenen eingefügt; {n} Arten von Inhalt wurden ausgelassen" } "Pattern" = "Muster" "Pattern alignment" = "Musterausrichtung" "Pattern scale" = "Musterskalierung" @@ -1730,6 +1731,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplizieren" "edit.findReplace" = "Suchen und ersetzen" "edit.paste" = "Einfügen" +"edit.pasteDesignLayers" = "Designebenen einfügen" "edit.pasteOver" = "Über Auswahl einfügen" "edit.pasteProperties" = "Eigenschaften einfügen" "edit.pasteReplace" = "Zum Ersetzen einfügen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 993e90ff..9c0b775f 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -955,6 +955,7 @@ name = "English" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" "Paste here" = "Paste here" "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" "Pattern alignment" = "Pattern alignment" "Pattern scale" = "Pattern scale" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 15ff741b..1b59f07e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -956,6 +956,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" "Paste here" = "Pegar aquí" "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" "Pattern alignment" = "Alineación del patrón" "Pattern scale" = "Escala del patrón" @@ -1809,6 +1810,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Buscar y reemplazar" "edit.paste" = "Pegar" +"edit.pasteDesignLayers" = "Pegar capas de diseño" "edit.pasteOver" = "Pegar sobre la selección" "edit.pasteProperties" = "Pegar propiedades" "edit.pasteReplace" = "Pegar para reemplazar" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1256ab72..ab1f4687 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -903,6 +903,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Collez le lien d’une bibliothèque d’équipe (un lien de consultation)" "Paste here" = "Coller ici" "Paste to replace" = "Coller pour remplacer" +"Pasted design layers; {n} kinds of content were left out" = { one = "Calques de design collés ; {n} type de contenu a été omis", other = "Calques de design collés ; {n} types de contenu ont été omis" } "Pattern" = "Motif" "Pattern alignment" = "Alignement du motif" "Pattern scale" = "Échelle du motif" @@ -1721,6 +1722,7 @@ status = "draft — needs native review" "edit.duplicate" = "Dupliquer" "edit.findReplace" = "Rechercher et remplacer" "edit.paste" = "Coller" +"edit.pasteDesignLayers" = "Coller des calques de design" "edit.pasteOver" = "Coller par-dessus la sélection" "edit.pasteProperties" = "Coller les propriétés" "edit.pasteReplace" = "Coller pour remplacer" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 24ac1070..e614512c 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -944,6 +944,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Tempel link pustaka tim (link akses lihat)" "Paste here" = "Tempel di sini" "Paste to replace" = "Tempel dan ganti" +"Pasted design layers; {n} kinds of content were left out" = { other = "Layer desain ditempel; {n} jenis konten tidak disertakan" } "Pattern" = "Pola" "Pattern alignment" = "Perataan pola" "Pattern scale" = "Skala pola" @@ -1788,6 +1789,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplikat" "edit.findReplace" = "Cari dan ganti" "edit.paste" = "Tempel" +"edit.pasteDesignLayers" = "Tempel layer desain" "edit.pasteOver" = "Tempel di atas pilihan" "edit.pasteProperties" = "Tempel properti" "edit.pasteReplace" = "Tempel dan ganti" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 9b17e92a..0657cfa8 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -949,6 +949,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け" "Paste here" = "ここに貼り付け" "Paste to replace" = "貼り付けて置換" +"Pasted design layers; {n} kinds of content were left out" = { other = "デザインレイヤーを貼り付け済み。{n} 種類のコンテンツは含まれていない" } "Pattern" = "パターン" "Pattern alignment" = "パターンの配置" "Pattern scale" = "パターンの拡大縮小" @@ -1799,6 +1800,7 @@ status = "draft — needs native review" "edit.duplicate" = "複製" "edit.findReplace" = "検索と置換" "edit.paste" = "貼り付け" +"edit.pasteDesignLayers" = "デザインレイヤーを貼り付け" "edit.pasteOver" = "選択範囲の上に貼り付け" "edit.pasteProperties" = "プロパティを貼り付け" "edit.pasteReplace" = "貼り付けて置換" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 825ca0c2..99135832 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -903,6 +903,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기" "Paste here" = "여기에 붙여 넣기" "Paste to replace" = "붙여 넣어 바꾸기" +"Pasted design layers; {n} kinds of content were left out" = { other = "디자인 레이어를 붙여 넣음. 콘텐츠 {n}종은 제외됨" } "Pattern" = "패턴" "Pattern alignment" = "패턴 정렬" "Pattern scale" = "패턴 배율" @@ -1721,6 +1722,7 @@ status = "draft — needs native review" "edit.duplicate" = "복제" "edit.findReplace" = "찾기 및 바꾸기" "edit.paste" = "붙여 넣기" +"edit.pasteDesignLayers" = "디자인 레이어 붙여 넣기" "edit.pasteOver" = "선택 항목 위에 붙여 넣기" "edit.pasteProperties" = "속성 붙여 넣기" "edit.pasteReplace" = "붙여 넣어 바꾸기" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 29e3dc4c..750a30d2 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -902,6 +902,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "Cole o link de uma biblioteca de equipe (um link de visualização)" "Paste here" = "Colar aqui" "Paste to replace" = "Colar para substituir" +"Pasted design layers; {n} kinds of content were left out" = { one = "Camadas de design coladas; {n} tipo de conteúdo ficou de fora", other = "Camadas de design coladas; {n} tipos de conteúdo ficaram de fora" } "Pattern" = "Padrão" "Pattern alignment" = "Alinhamento do padrão" "Pattern scale" = "Escala do padrão" @@ -1720,6 +1721,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Localizar e substituir" "edit.paste" = "Colar" +"edit.pasteDesignLayers" = "Colar camadas de design" "edit.pasteOver" = "Colar sobre a seleção" "edit.pasteProperties" = "Colar propriedades" "edit.pasteReplace" = "Colar para substituir" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index a7f0e646..57578117 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -941,6 +941,7 @@ name = "Русский" "Paste a team library link (a view link)" = "Вставьте ссылку на командную библиотеку (для просмотра)" "Paste here" = "Вставить здесь" "Paste to replace" = "Вставить с заменой" +"Pasted design layers; {n} kinds of content were left out" = { one = "Слои дизайна вставлены; пропущен {n} тип содержимого", few = "Слои дизайна вставлены; пропущены {n} типа содержимого", many = "Слои дизайна вставлены; пропущено {n} типов содержимого", other = "Слои дизайна вставлены; пропущено {n} типа содержимого" } "Pattern" = "Паттерн" "Pattern alignment" = "Выравнивание паттерна" "Pattern scale" = "Масштаб паттерна" @@ -1785,6 +1786,7 @@ name = "Русский" "edit.duplicate" = "Дублировать" "edit.findReplace" = "Найти и заменить" "edit.paste" = "Вставить" +"edit.pasteDesignLayers" = "Вставить слои дизайна" "edit.pasteOver" = "Вставить поверх выделения" "edit.pasteProperties" = "Вставить свойства" "edit.pasteReplace" = "Вставить с заменой" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 3295c4e3..b463daa6 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -942,6 +942,7 @@ name = "Svenska" "Paste a team library link (a view link)" = "Klistra in en länk till ett teambibliotek (en visningslänk)" "Paste here" = "Klistra in här" "Paste to replace" = "Klistra in och ersätt" +"Pasted design layers; {n} kinds of content were left out" = { one = "Designlager inklistrade; {n} typ av innehåll utelämnades", other = "Designlager inklistrade; {n} typer av innehåll utelämnades" } "Pattern" = "Mönster" "Pattern alignment" = "Mönsterjustering" "Pattern scale" = "Mönsterskala" @@ -1667,6 +1668,7 @@ name = "Svenska" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" +"edit.pasteDesignLayers" = "Klistra in designlager" "edit.pasteOver" = "Klistra in över" "edit.pasteProperties" = "Klistra in egenskaper" "edit.pasteReplace" = "Klistra in och ersätt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 436372f0..5b0edcf5 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -944,6 +944,7 @@ name = "Türkçe" "Paste a team library link (a view link)" = "Bir ekip kitaplığı bağlantısı yapıştırın (görüntüleme bağlantısı)" "Paste here" = "Buraya yapıştır" "Paste to replace" = "Değiştirmek için yapıştır" +"Pasted design layers; {n} kinds of content were left out" = { other = "Tasarım katmanları yapıştırıldı; {n} tür içerik dışarıda bırakıldı" } "Pattern" = "Desen" "Pattern alignment" = "Desen hizalaması" "Pattern scale" = "Desen ölçeği" @@ -1788,6 +1789,7 @@ name = "Türkçe" "edit.duplicate" = "Çoğalt" "edit.findReplace" = "Bul ve değiştir" "edit.paste" = "Yapıştır" +"edit.pasteDesignLayers" = "Tasarım katmanlarını yapıştır" "edit.pasteOver" = "Seçimin üzerine yapıştır" "edit.pasteProperties" = "Özellikleri yapıştır" "edit.pasteReplace" = "Değiştirmek için yapıştır" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 4f81f485..3ed358ac 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -904,6 +904,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" "Paste here" = "粘贴到此处" "Paste to replace" = "粘贴以替换" +"Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" } "Pattern" = "图案" "Pattern alignment" = "图案对齐" "Pattern scale" = "图案缩放" @@ -1722,6 +1723,7 @@ status = "draft — needs native review" "edit.duplicate" = "创建副本" "edit.findReplace" = "查找和替换" "edit.paste" = "粘贴" +"edit.pasteDesignLayers" = "粘贴设计图层" "edit.pasteOver" = "粘贴到所选项上方" "edit.pasteProperties" = "粘贴属性" "edit.pasteReplace" = "粘贴以替换" diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs new file mode 100644 index 00000000..ad33e005 --- /dev/null +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -0,0 +1,64 @@ +use figcraft_engine::Session; +use figcraft_engine::cmd::edit::{CLIP_CLOSE, CLIP_OPEN}; +use serde_json::json; + +use crate::{FigApp, Services}; + +struct Clip { + html: String, + text: Option, +} + +impl Services for Clip { + fn clipboard_get_html(&mut self) -> Option { + Some(self.html.clone()) + } + fn clipboard_get_text(&mut self) -> Option { + self.text.clone() + } +} + +/// The HTML another design app puts on the clipboard for copied layers (a `.fig` canvas inside). +fn copied_html() -> String { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "Copied layer"})).unwrap(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + format!(r#""#, r["data"].as_str().unwrap()) +} + +fn selected_names(app: &FigApp) -> Vec { + let doc = app.session.doc().unwrap(); + app.session.selection().iter().map(|i| doc.node(*i).unwrap().name.clone()).collect() +} + +fn pasted_text(app: &FigApp) -> String { + let doc = app.session.doc().unwrap(); + let id = app.session.selection()[0]; + doc.node(id).unwrap().kind.text().unwrap().characters.clone() +} + +#[test] +fn paste_takes_layers_copied_in_another_design_app() { + let html = copied_html(); + let mut app = FigApp::new(Session::new(), Box::new(Clip { html, text: Some(String::new()) })); + crate::menus::paste(&mut app, None); + assert_eq!(selected_names(&app), ["Copied layer"]); +} + +#[test] +fn html_from_other_apps_pastes_as_before() { + let clip = Clip { html: "Hello".into(), text: Some("Hello".into()) }; + let mut app = FigApp::new(Session::new(), Box::new(clip)); + crate::menus::paste(&mut app, None); + assert_eq!(pasted_text(&app), "Hello"); +} + +#[test] +fn html_that_only_mentions_the_marker_pastes_its_text() { + let text = format!("Markers {CLIP_OPEN} and {CLIP_CLOSE} explained"); + let clip = Clip { html: format!("

{text}

"), text: Some(text.clone()) }; + let mut app = FigApp::new(Session::new(), Box::new(clip)); + crate::menus::paste(&mut app, None); + assert_eq!(pasted_text(&app), text); + assert!(app.ui.last_error.is_none(), "{:?}", app.ui.last_error); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1eb9ac78..ae8e38ec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,8 @@ mod comments_tests; #[cfg(test)] mod cursors_tests; #[cfg(test)] +mod design_paste_tests; +#[cfg(test)] mod dev_mode_tests; #[cfg(test)] mod duplicate_paint_tests; @@ -231,6 +233,9 @@ pub trait Services { fn clipboard_get_text(&mut self) -> Option { None } + fn clipboard_get_html(&mut self) -> Option { + None + } fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {} /// An image on the system clipboard as (width, height, straight RGBA). fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 31b53429..6a2ea0ea 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,7 +7,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::i18n::{self, tr, tr_noop, trf}; +use crate::i18n::{self, tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular}; use crate::toolbar::pretty_shortcut; @@ -30,10 +30,21 @@ pub const UI_COMMANDS: &[&str] = &[ ]; /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. -/// Paste: the system clipboard wins when it holds something newer than our own copy (an image, SVG -/// or text from another app); otherwise our copied layers. `event_text` is the text the platform -/// handed over with the paste event (the browser has no other way to read it). +/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied +/// from another design app, an image, SVG or text from another app); otherwise our copied layers. +/// `event_text` is the text the platform handed over with the paste event (the browser has no +/// other way to read it). pub fn paste(app: &mut FigApp, event_text: Option<&str>) { + // Only HTML that holds readable design data takes this path; other HTML pastes as before. + if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { + if let Some(r) = app.run("edit.pasteDesignLayers", json!({"html": html})) { + let left_out = r["warnings"].as_array().map_or(0, Vec::len); + if left_out > 0 { + app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); + } + } + return; + } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0));