Merge fix/647

This commit is contained in:
bflatastic
2026-10-17 14:41:03 +07:00
21 changed files with 307 additions and 19 deletions
+3
View File
@@ -254,6 +254,9 @@ impl Services for Desktop {
fn clipboard_get_text(&mut self) -> Option<String> {
self.clipboard.as_mut()?.get_text().ok()
}
fn clipboard_get_html(&mut self) -> Option<String> {
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) });
+74 -11
View File
@@ -25,6 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
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<Node>)>, BTreeMap<String, ImageData>, Option<figcraft_geom::Rect>);
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<Vec<u8>> {
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<Vec<u8>> {
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<Value> {
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<NodeId> = 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<Value> {
+11 -4
View File
@@ -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<Result<figcraft_format::Loaded>> {
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) {
+108
View File
@@ -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<u8> {
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#"<meta charset="utf-8"><span data-buffer="<!--{CLIP_OPEN}{wrapped}{CLIP_CLOSE}-->"></span>"#)
}
fn names(s: &Session, ids: &[NodeId]) -> Vec<String> {
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("<p>plain</p>"), 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!("<p>see {CLIP_OPEN} and {CLIP_CLOSE} in the docs</p>")), 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<NodeId> = 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<NodeId> = 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": "<b>hi</b>"})).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());
}
+2
View File
@@ -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;
+2
View File
@@ -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"
+2
View File
@@ -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"
+1
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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" = "貼り付けて置換"
+2
View File
@@ -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" = "붙여 넣어 바꾸기"
+2
View File
@@ -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"
+2
View File
@@ -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" = "Вставить с заменой"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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" = "粘贴以替换"
+64
View File
@@ -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<String>,
}
impl Services for Clip {
fn clipboard_get_html(&mut self) -> Option<String> {
Some(self.html.clone())
}
fn clipboard_get_text(&mut self) -> Option<String> {
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#"<span data-buffer="<!--{CLIP_OPEN}{}{CLIP_CLOSE}-->"></span>"#, r["data"].as_str().unwrap())
}
fn selected_names(app: &FigApp) -> Vec<String> {
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: "<b>Hello</b>".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!("<p>{text}</p>"), 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);
}
+5
View File
@@ -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<String> {
None
}
fn clipboard_get_html(&mut self) -> Option<String> {
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<u8>)> {
+15 -4
View File
@@ -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));