mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
Merge fix/647
This commit is contained in:
@@ -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) });
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "貼り付けて置換"
|
||||
|
||||
@@ -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" = "붙여 넣어 바꾸기"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "Вставить с заменой"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "粘贴以替换"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>)> {
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user