From ab10e37d434ec09e8bcf0e11f62783439a522820 Mon Sep 17 00:00:00 2001 From: JimKnoxx Date: Wed, 14 Oct 2026 08:47:59 +0200 Subject: [PATCH 1/4] M1: paste layers copied in Figma Figma puts copied layers in the clipboard's HTML as a base64 .fig canvas between (figma) markers. edit.pasteFromFigma reads it with the .fig import, turns the layers into the session clipboard and pastes them as edit.paste does (selected frame, in place or view centre), with the import's warnings. The desktop app reads the clipboard's HTML (arboard) and Paste prefers a Figma copy; the browser's paste event carries plain text only. Tested on copies written by our own .fig export; not yet on real copies. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft/src/services.rs | 3 + crates/engine/src/cmd/edit.rs | 78 ++++++++++++++++++--- crates/engine/src/cmd/file.rs | 15 ++-- crates/engine/src/figma_paste_tests.rs | 92 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/fr.toml | 2 + crates/ui-egui/locales/ja.toml | 2 + crates/ui-egui/locales/ko.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + crates/ui-egui/locales/zh-Hans.toml | 2 + crates/ui-egui/src/figma_paste_tests.rs | 48 +++++++++++++ crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menus.rs | 17 +++-- docs/file-format-parity.md | 5 +- 17 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/figma_paste_tests.rs create mode 100644 crates/ui-egui/src/figma_paste_tests.rs diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..334f007b 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -241,6 +241,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..27d9652e 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.pasteFromFigma", + "Paste from Figma", + "{html (the clipboard's HTML) | data (base64 of the copied buffer), parent?, x?, y?} layers copied in Figma, read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + true, + paste_from_figma + ), 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,52 @@ 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) +} + +const MAX_FIGMA_CLIP: usize = 512 * 1024 * 1024; + +/// Figma puts the copied layers in the HTML as `(figma)(/figma)`. This gives those bytes. +pub fn figma_clipboard_buffer(html: &str) -> Option> { + use base64::Engine as _; + const OPEN: &str = "(figma)"; + let start = html.find(OPEN)? + OPEN.len(); + let len = html.get(start..)?.find("(/figma)")?; + let text = html.get(start..start + len)?; + if text.len() > MAX_FIGMA_CLIP { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + let bytes = + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +fn paste_from_figma(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteFromFigma"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => figma_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no layers copied in Figma"))?, + (None, Some(d)) => { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad(CMD, e.to_string()))? + } + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = super::file::load_fig(&bytes, "Pasted from Figma").ok_or_else(|| bad(CMD, "the data is not layers copied in Figma"))??; + 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 5e27c572..fc78697c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -506,10 +506,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()))?; @@ -525,6 +523,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/figma_paste_tests.rs b/crates/engine/src/figma_paste_tests.rs new file mode 100644 index 00000000..7468f3ac --- /dev/null +++ b/crates/engine/src/figma_paste_tests.rs @@ -0,0 +1,92 @@ +use base64::Engine as _; +use serde_json::json; + +use super::*; +use crate::cmd::edit::figma_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 meta = B64.encode(br#"{"dataType":"scene"}"#); + 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!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file)); + assert_eq!(figma_clipboard_buffer("

plain

"), None); + assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None); + assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None); + assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None); +} + +#[test] +fn layers_paste_into_the_page_and_undo() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("edit.pasteFromFigma", &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_figma_layers() { + let mut s = Session::new(); + s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).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.pasteFromFigma", &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.pasteFromFigma", &json!({})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"html": "hi"})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); + assert!(s.execute("edit.pasteFromFigma", &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 34653bd5..908c952a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1528,6 +1528,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod figma_paste_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..e4749235 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -899,6 +899,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 from Figma; {n} kinds of content were left out" = { one = "Aus Figma eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Aus Figma eingefügt; {n} Arten von Inhalt wurden ausgelassen" } "Pattern" = "Muster" "Pattern alignment" = "Musterausrichtung" "Pattern scale" = "Musterskalierung" @@ -1654,6 +1655,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplizieren" "edit.findReplace" = "Suchen und ersetzen" "edit.paste" = "Einfügen" +"edit.pasteFromFigma" = "Aus Figma 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 3c127fc6..f0865ea4 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -943,6 +943,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 from Figma; {n} kinds of content were left out" = { one = "Pasted from Figma; {n} kind of content was left out", other = "Pasted from Figma; {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 901283f1..3a07c3ab 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -944,6 +944,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 from Figma; {n} kinds of content were left out" = { one = "Pegado desde Figma; se omitió {n} tipo de contenido", other = "Pegado desde Figma; se omitieron {n} tipos de contenido" } "Pattern" = "Patrón" "Pattern alignment" = "Alineación del patrón" "Pattern scale" = "Escala del patrón" @@ -1731,6 +1732,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Buscar y reemplazar" "edit.paste" = "Pegar" +"edit.pasteFromFigma" = "Pegar desde Figma" "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 7fafd3c9..2f5ae5b9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -893,6 +893,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 from Figma; {n} kinds of content were left out" = { one = "Collé depuis Figma ; {n} type de contenu a été omis", other = "Collé depuis Figma ; {n} types de contenu ont été omis" } "Pattern" = "Motif" "Pattern alignment" = "Alignement du motif" "Pattern scale" = "Échelle du motif" @@ -1645,6 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "Dupliquer" "edit.findReplace" = "Rechercher et remplacer" "edit.paste" = "Coller" +"edit.pasteFromFigma" = "Coller depuis Figma" "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/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..493bf999 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -939,6 +939,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け" "Paste here" = "ここに貼り付け" "Paste to replace" = "貼り付けて置換" +"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma から貼り付け済み。{n} 種類のコンテンツは含まれていない" } "Pattern" = "パターン" "Pattern alignment" = "パターンの配置" "Pattern scale" = "パターンの拡大縮小" @@ -1723,6 +1724,7 @@ status = "draft — needs native review" "edit.duplicate" = "複製" "edit.findReplace" = "検索と置換" "edit.paste" = "貼り付け" +"edit.pasteFromFigma" = "Figma から貼り付け" "edit.pasteOver" = "選択範囲の上に貼り付け" "edit.pasteProperties" = "プロパティを貼り付け" "edit.pasteReplace" = "貼り付けて置換" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..253cc54e 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -893,6 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기" "Paste here" = "여기에 붙여 넣기" "Paste to replace" = "붙여 넣어 바꾸기" +"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma에서 붙여 넣음. 콘텐츠 {n}종은 제외됨" } "Pattern" = "패턴" "Pattern alignment" = "패턴 정렬" "Pattern scale" = "패턴 배율" @@ -1645,6 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "복제" "edit.findReplace" = "찾기 및 바꾸기" "edit.paste" = "붙여 넣기" +"edit.pasteFromFigma" = "Figma에서 붙여 넣기" "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 c2189de8..12e7c0b2 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -892,6 +892,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 from Figma; {n} kinds of content were left out" = { one = "Colado do Figma; {n} tipo de conteúdo ficou de fora", other = "Colado do Figma; {n} tipos de conteúdo ficaram de fora" } "Pattern" = "Padrão" "Pattern alignment" = "Alinhamento do padrão" "Pattern scale" = "Escala do padrão" @@ -1644,6 +1645,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Localizar e substituir" "edit.paste" = "Colar" +"edit.pasteFromFigma" = "Colar do Figma" "edit.pasteOver" = "Colar sobre a seleção" "edit.pasteProperties" = "Colar propriedades" "edit.pasteReplace" = "Colar para substituir" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..91c251ec 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -894,6 +894,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" "Paste here" = "粘贴到此处" "Paste to replace" = "粘贴以替换" +"Pasted from Figma; {n} kinds of content were left out" = { other = "已从 Figma 粘贴;有 {n} 类内容未包含" } "Pattern" = "图案" "Pattern alignment" = "图案对齐" "Pattern scale" = "图案缩放" @@ -1646,6 +1647,7 @@ status = "draft — needs native review" "edit.duplicate" = "创建副本" "edit.findReplace" = "查找和替换" "edit.paste" = "粘贴" +"edit.pasteFromFigma" = "从 Figma 粘贴" "edit.pasteOver" = "粘贴到所选项上方" "edit.pasteProperties" = "粘贴属性" "edit.pasteReplace" = "粘贴以替换" diff --git a/crates/ui-egui/src/figma_paste_tests.rs b/crates/ui-egui/src/figma_paste_tests.rs new file mode 100644 index 00000000..6147ba5e --- /dev/null +++ b/crates/ui-egui/src/figma_paste_tests.rs @@ -0,0 +1,48 @@ +use figcraft_engine::Session; +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() + } +} + +fn figma_html() -> String { + let mut s = Session::new(); + s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20, "name": "From Figma"})).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() +} + +#[test] +fn paste_takes_layers_copied_in_figma() { + let html = figma_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), ["From Figma"]); +} + +#[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); + let doc = app.session.doc().unwrap(); + let id = app.session.selection()[0]; + assert_eq!(doc.node(id).unwrap().kind.text().unwrap().characters, "Hello"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..04fe3861 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,8 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] +mod figma_paste_tests; +#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; @@ -226,6 +228,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 b9d3299a..60adf027 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,19 @@ 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 +/// in Figma, 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>) { + if let Some(html) = app.services.clipboard_get_html().filter(|h| h.contains("(figma)")) { + if let Some(r) = app.run("edit.pasteFromFigma", json!({"html": html})) { + let left_out = r["warnings"].as_array().map_or(0, Vec::len); + if left_out > 0 { + app.toast(trn("Pasted from Figma; {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)); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8e51711..63f281f6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (paste from Figma (desktop); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -36,7 +36,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text, **layers copied in Figma** (desktop; best effort) (newest clipboard content wins) | layers copied in Figma arrive as a `.fig` canvas in the clipboard's HTML; `edit.pasteFromFigma` reads it with the `.fig` import and pastes it as Paste does (into the selected frame, in place or in the view's centre), with `warnings`. Image fills come without their pixels (the clipboard doesn't carry them). Not in the browser (its paste event carries plain text only). Tested on copies written by our own `.fig` export, not yet on real Figma copies | with `.fig` | ## Export @@ -162,6 +162,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Paste from Figma (desktop): layers copied in Figma paste as layers. Paste reads the clipboard's HTML; its `.fig` canvas goes through the `.fig` import and lands as Paste places our own layers (`edit.pasteFromFigma`, with `warnings`). Tested on copies written by our own `.fig` export only. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 946006f1007c8a17118e50dd496017920a30ad4f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:56 +0700 Subject: [PATCH 2/4] #647: leave progress docs to the maintainer's docs PR --- docs/file-format-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63f281f6..d8e51711 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (paste from Figma (desktop); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -36,7 +36,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text, **layers copied in Figma** (desktop; best effort) (newest clipboard content wins) | layers copied in Figma arrive as a `.fig` canvas in the clipboard's HTML; `edit.pasteFromFigma` reads it with the `.fig` import and pastes it as Paste does (into the selected frame, in place or in the view's centre), with `warnings`. Image fills come without their pixels (the clipboard doesn't carry them). Not in the browser (its paste event carries plain text only). Tested on copies written by our own `.fig` export, not yet on real Figma copies | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` | ## Export @@ -162,7 +162,6 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-14 | minor | Paste from Figma (desktop): layers copied in Figma paste as layers. Paste reads the clipboard's HTML; its `.fig` canvas goes through the `.fig` import and lands as Paste places our own layers (`edit.pasteFromFigma`, with `warnings`). Tested on copies written by our own `.fig` export only. Numbers unchanged | | 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. | | 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | | 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged | From 3353d2d5d3c399ec4c586f35ea5ccb0edef8a10a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:57 +0700 Subject: [PATCH 3/4] #647: edit.pasteDesignLayers, named neutrally; design data capped at 256 MB before decoding, data accepts wrapped or unpadded base64 --- crates/engine/src/cmd/edit.rs | 51 ++++++++++-------- ...a_paste_tests.rs => design_paste_tests.rs} | 52 ++++++++++++------- crates/engine/src/lib.rs | 4 +- ...a_paste_tests.rs => design_paste_tests.rs} | 0 4 files changed, 65 insertions(+), 42 deletions(-) rename crates/engine/src/{figma_paste_tests.rs => design_paste_tests.rs} (53%) rename crates/ui-egui/src/{figma_paste_tests.rs => design_paste_tests.rs} (100%) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 27d9652e..21f09569 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -26,11 +26,11 @@ pub fn specs() -> Vec { 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.pasteFromFigma", - "Paste from Figma", - "{html (the clipboard's HTML) | data (base64 of the copied buffer), parent?, x?, y?} layers copied in Figma, read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + "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_from_figma + paste_design_layers ), spec!("edit.pasteOver", "Paste over selection", "{}", true, paste_over), spec!("edit.pasteReplace", "Paste to replace", "{ids?}", true, paste_replace), @@ -544,35 +544,42 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied { (nodes, images, bounds) } -const MAX_FIGMA_CLIP: usize = 512 * 1024 * 1024; +/// 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; -/// Figma puts the copied layers in the HTML as `(figma)(/figma)`. This gives those bytes. -pub fn figma_clipboard_buffer(html: &str) -> Option> { +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { use base64::Engine as _; - const OPEN: &str = "(figma)"; - let start = html.find(OPEN)? + OPEN.len(); - let len = html.get(start..)?.find("(/figma)")?; - let text = html.get(start..start + len)?; - if text.len() > MAX_FIGMA_CLIP { + 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(); - let bytes = - base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok()?; + 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_from_figma(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "edit.pasteFromFigma"; +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), _) => figma_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no layers copied in Figma"))?, - (None, Some(d)) => { - use base64::Engine as _; - base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad(CMD, e.to_string()))? - } + (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 from Figma").ok_or_else(|| bad(CMD, "the data is not layers copied in Figma"))??; + 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() { diff --git a/crates/engine/src/figma_paste_tests.rs b/crates/engine/src/design_paste_tests.rs similarity index 53% rename from crates/engine/src/figma_paste_tests.rs rename to crates/engine/src/design_paste_tests.rs index 7468f3ac..89de80ef 100644 --- a/crates/engine/src/figma_paste_tests.rs +++ b/crates/engine/src/design_paste_tests.rs @@ -1,8 +1,11 @@ +//! 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::figma_clipboard_buffer; +use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; @@ -20,12 +23,9 @@ fn copied_file() -> Vec { } fn clipboard_html(buffer: &[u8]) -> String { - let meta = B64.encode(br#"{"dataType":"scene"}"#); let mut wrapped = B64.encode(buffer); wrapped.insert(8, '\n'); - format!( - r#""# - ) + format!(r#""#) } fn names(s: &Session, ids: &[NodeId]) -> Vec { @@ -36,18 +36,31 @@ fn names(s: &Session, ids: &[NodeId]) -> Vec { #[test] fn buffer_is_found_in_the_html() { let file = copied_file(); - assert_eq!(figma_clipboard_buffer(&clipboard_html(&file)), Some(file)); - assert_eq!(figma_clipboard_buffer("

plain

"), None); - assert_eq!(figma_clipboard_buffer("(figma)not base64!(/figma)"), None); - assert_eq!(figma_clipboard_buffer(&format!("(figma){}(/figma)", B64.encode(b"hello"))), None); - assert_eq!(figma_clipboard_buffer("(figma)ZmlnLQ"), None); + 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.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).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); @@ -61,9 +74,12 @@ fn layers_paste_into_the_page_and_undo() { } #[test] -fn a_second_paste_repeats_the_figma_layers() { +fn a_second_paste_repeats_the_copied_layers() { let mut s = Session::new(); - s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(copied_file())})).unwrap(); + // `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"]); @@ -74,7 +90,7 @@ 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.pasteFromFigma", &json!({"html": clipboard_html(&copied_file())})).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)); } @@ -83,10 +99,10 @@ fn pastes_into_the_selected_frame() { fn other_content_is_refused_without_changes() { let mut s = Session::new(); let before = s.doc().unwrap().nodes().count(); - assert!(s.execute("edit.pasteFromFigma", &json!({})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"html": "hi"})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"PK not a zip")})).is_err()); - assert!(s.execute("edit.pasteFromFigma", &json!({"data": B64.encode(b"fig-kiwi\x00\x00\x00\x00junk")})).is_err()); + 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 908c952a..b124c1e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1500,6 +1500,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; @@ -1528,8 +1530,6 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] -mod figma_paste_tests; -#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; diff --git a/crates/ui-egui/src/figma_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs similarity index 100% rename from crates/ui-egui/src/figma_paste_tests.rs rename to crates/ui-egui/src/design_paste_tests.rs From d07b55104d54a8c487aa6e17c0557dbeaecb7469 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 14:34:58 +0700 Subject: [PATCH 4/4] #647: paste takes the design path only when the HTML holds readable design data; neutral wording in every catalog --- crates/ui-egui/locales/cs.toml | 2 ++ crates/ui-egui/locales/de.toml | 4 +-- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 4 +-- crates/ui-egui/locales/fr.toml | 4 +-- crates/ui-egui/locales/id.toml | 2 ++ crates/ui-egui/locales/ja.toml | 4 +-- crates/ui-egui/locales/ko.toml | 4 +-- crates/ui-egui/locales/pt-BR.toml | 4 +-- crates/ui-egui/locales/ru.toml | 2 ++ crates/ui-egui/locales/sv.toml | 2 ++ crates/ui-egui/locales/tr.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 4 +-- crates/ui-egui/src/design_paste_tests.rs | 34 +++++++++++++++++------- crates/ui-egui/src/lib.rs | 4 +-- crates/ui-egui/src/menus.rs | 12 +++++---- 16 files changed, 59 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..2504193a 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -930,6 +930,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" @@ -1708,6 +1709,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 e4749235..3240a642 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -899,7 +899,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 from Figma; {n} kinds of content were left out" = { one = "Aus Figma eingefügt; {n} Art von Inhalt wurde ausgelassen", other = "Aus Figma eingefügt; {n} Arten von Inhalt wurden ausgelassen" } +"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" @@ -1655,7 +1655,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplizieren" "edit.findReplace" = "Suchen und ersetzen" "edit.paste" = "Einfügen" -"edit.pasteFromFigma" = "Aus Figma 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 def0b827..627d0155 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -945,7 +945,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 from Figma; {n} kinds of content were left out" = { one = "Pasted from Figma; {n} kind of content was left out", other = "Pasted from Figma; {n} kinds of content were left out" } +"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 38c868b6..a6bbb17a 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -946,7 +946,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 from Figma; {n} kinds of content were left out" = { one = "Pegado desde Figma; se omitió {n} tipo de contenido", other = "Pegado desde Figma; se omitieron {n} tipos de contenido" } +"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" @@ -1734,7 +1734,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Buscar y reemplazar" "edit.paste" = "Pegar" -"edit.pasteFromFigma" = "Pegar desde Figma" +"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 2f5ae5b9..8074a006 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -893,7 +893,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 from Figma; {n} kinds of content were left out" = { one = "Collé depuis Figma ; {n} type de contenu a été omis", other = "Collé depuis Figma ; {n} types de contenu ont été omis" } +"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" @@ -1646,7 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "Dupliquer" "edit.findReplace" = "Rechercher et remplacer" "edit.paste" = "Coller" -"edit.pasteFromFigma" = "Coller depuis Figma" +"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 5de7634d..c39cd73f 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -934,6 +934,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" @@ -1712,6 +1713,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 493bf999..bb6938de 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -939,7 +939,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "チームライブラリのリンク(閲覧リンク)を貼り付け" "Paste here" = "ここに貼り付け" "Paste to replace" = "貼り付けて置換" -"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma から貼り付け済み。{n} 種類のコンテンツは含まれていない" } +"Pasted design layers; {n} kinds of content were left out" = { other = "デザインレイヤーを貼り付け済み。{n} 種類のコンテンツは含まれていない" } "Pattern" = "パターン" "Pattern alignment" = "パターンの配置" "Pattern scale" = "パターンの拡大縮小" @@ -1724,7 +1724,7 @@ status = "draft — needs native review" "edit.duplicate" = "複製" "edit.findReplace" = "検索と置換" "edit.paste" = "貼り付け" -"edit.pasteFromFigma" = "Figma から貼り付け" +"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 253cc54e..65e9296c 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -893,7 +893,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "팀 라이브러리 링크(보기 링크) 붙여 넣기" "Paste here" = "여기에 붙여 넣기" "Paste to replace" = "붙여 넣어 바꾸기" -"Pasted from Figma; {n} kinds of content were left out" = { other = "Figma에서 붙여 넣음. 콘텐츠 {n}종은 제외됨" } +"Pasted design layers; {n} kinds of content were left out" = { other = "디자인 레이어를 붙여 넣음. 콘텐츠 {n}종은 제외됨" } "Pattern" = "패턴" "Pattern alignment" = "패턴 정렬" "Pattern scale" = "패턴 배율" @@ -1646,7 +1646,7 @@ status = "draft — needs native review" "edit.duplicate" = "복제" "edit.findReplace" = "찾기 및 바꾸기" "edit.paste" = "붙여 넣기" -"edit.pasteFromFigma" = "Figma에서 붙여 넣기" +"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 12e7c0b2..b6d21e02 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -892,7 +892,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 from Figma; {n} kinds of content were left out" = { one = "Colado do Figma; {n} tipo de conteúdo ficou de fora", other = "Colado do Figma; {n} tipos de conteúdo ficaram de fora" } +"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" @@ -1645,7 +1645,7 @@ status = "draft — needs native review" "edit.duplicate" = "Duplicar" "edit.findReplace" = "Localizar e substituir" "edit.paste" = "Colar" -"edit.pasteFromFigma" = "Colar do Figma" +"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 3ab40757..7a64557e 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -931,6 +931,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" = "Масштаб паттерна" @@ -1709,6 +1710,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 f7b2999d..c3b7f746 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -932,6 +932,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" @@ -1591,6 +1592,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 a2be6b99..531b6526 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -934,6 +934,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" @@ -1712,6 +1713,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 91c251ec..153865bd 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -894,7 +894,7 @@ status = "draft — needs native review" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" "Paste here" = "粘贴到此处" "Paste to replace" = "粘贴以替换" -"Pasted from Figma; {n} kinds of content were left out" = { other = "已从 Figma 粘贴;有 {n} 类内容未包含" } +"Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" } "Pattern" = "图案" "Pattern alignment" = "图案对齐" "Pattern scale" = "图案缩放" @@ -1647,7 +1647,7 @@ status = "draft — needs native review" "edit.duplicate" = "创建副本" "edit.findReplace" = "查找和替换" "edit.paste" = "粘贴" -"edit.pasteFromFigma" = "从 Figma 粘贴" +"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 index 6147ba5e..ad33e005 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -1,4 +1,5 @@ use figcraft_engine::Session; +use figcraft_engine::cmd::edit::{CLIP_CLOSE, CLIP_OPEN}; use serde_json::json; use crate::{FigApp, Services}; @@ -17,11 +18,12 @@ impl Services for Clip { } } -fn figma_html() -> String { +/// 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": "From Figma"})).unwrap(); + 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()) + format!(r#""#, r["data"].as_str().unwrap()) } fn selected_names(app: &FigApp) -> Vec { @@ -29,12 +31,18 @@ fn selected_names(app: &FigApp) -> Vec { 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_figma() { - let html = figma_html(); +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), ["From Figma"]); + assert_eq!(selected_names(&app), ["Copied layer"]); } #[test] @@ -42,7 +50,15 @@ 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); - let doc = app.session.doc().unwrap(); - let id = app.session.selection()[0]; - assert_eq!(doc.node(id).unwrap().kind.text().unwrap().characters, "Hello"); + 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 04fe3861..e325f4df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,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; @@ -94,8 +96,6 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] -mod figma_paste_tests; -#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 36dfd237..8bd98cc5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -31,14 +31,16 @@ 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 (layers copied -/// in Figma, 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). +/// 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>) { - if let Some(html) = app.services.clipboard_get_html().filter(|h| h.contains("(figma)")) { - if let Some(r) = app.run("edit.pasteFromFigma", json!({"html": html})) { + // 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 from Figma; {n} kinds of content were left out", left_out, &[])); + app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); } } return;