Browse / github.com/storytold/vectorcraft / patch
Clipboard: a bitmap pasted from another app is decoded off the UI thread
Clipboard: a bitmap pasted from another app is decoded off the UI thread ## What and why A Paste of a bitmap another app copied ran on the UI thread from the read to the paste. On Windows a screenshot's device-independent bitmap was decoded and encoded as PNG (`apps/vectorcraft/src/clipboard.rs:283-288`), then the PNG went through base64 into `clipboard.importImage` (`crates/ui-egui/src/sysclip.rs:57`), which decoded the base64 again. Timed in a release build with a 6000 × 4000 bitmap, these steps took 1.3 to 1.6 s, 0.85 to 1.06 s of it in the base64 decode; after this change the UI thread spends 10 to 12 ms on them, copying the bitmap out of the clipboard. - A P
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #4277, open)
- Base
9fc09feb8066d77ef8cd4155a39af369aa64c0e9- Size
- +968 −40 in 36 files
- Review
- pending
- Shared
- 2026-10-28
- Id
292151b5fd48924c21ec10ab
How it went
No other operator has reported using it yet.
Diff
raw · apps/vectorcraft/src/clipboard.rs crates/engine/src/cmd/clipboard.rs crates/engine/src/cmd/clipboard/flavours.rs crates/engine/src/lib.rs crates/engine/src/tests_clipflavours.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/tr.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/placing.rs crates/ui-egui/src/sysclip.rs docs/control-protocol.md docs/mcp.md docs/target-app-parity.md
@@ -17,7 +17,7 @@ use std::io::{Cursor, Read}; use std::path::PathBuf; use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};-use vectorcraft_ui_egui::SystemClipboard;+use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard}; /// This platform's system clipboard. pub fn system_clipboard() -> Box<dyn SystemClipboard> {@@ -240,10 +240,16 @@ mod win { // A clipboard manager may have copied our data again as its own. let Some(ours) = self.text.as_deref() else { return false }; let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };- read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes())+ read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes()) } fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {+ self.read_deferred(mimes)?.flavour()+ }++ /// A device-independent bitmap is copied off the clipboard here and decoded and encoded+ /// as PNG by the work that comes back.+ fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> { let mut files = vec![]; { let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;@@ -251,7 +257,7 @@ mod win { let _ = raw::get_file_list_path(&mut files); } // The files are read with the clipboard closed again, not keeping other apps waiting.- copied_file(files, mimes).or_else(|| {+ copied_file(files, mimes).map(ClipboardRead::Now).or_else(|| { let _open = clipboard_win::Clipboard::new_attempts(10).ok()?; mimes.iter().find_map(|m| read_one(m)) })@@ -275,17 +281,39 @@ mod win { }) } - /// One flavour, while the clipboard is open.- fn read_one(mime: &'static str) -> Option<Flavour> {+ /// A device-independent bitmap as a PNG flavour.+ fn dib_png(dib: &[u8]) -> Option<Flavour> {+ png_flavour(&dib_image(dib)?)+ }++ /// One flavour, while the clipboard is open. A device-independent bitmap is copied here, and+ /// the work that comes back decodes it. When it can't be decoded, that work reads the next of+ /// [`DIBS`] the clipboard holds, unless something was copied since.+ fn read_one(mime: &'static str) -> Option<ClipboardRead> { let mut data = vec![]; let mime = match mime { BITMAP if read_registered(PNG, &mut data) => PNG, BITMAP => {- return DIBS.into_iter().find_map(|id| {- data.clear();- raw::get_vec(id, &mut data).ok()?;- png_flavour(&dib_image(&data)?)- });+ let seq = raw::seq_num();+ let (at, dib) = DIBS.into_iter().enumerate().find_map(|(at, id)| {+ let mut dib = vec![];+ raw::get_vec(id, &mut dib).ok()?;+ Some((at, dib))+ })?;+ let rest = DIBS.get(at + 1..).unwrap_or_default().to_vec();+ return Some(ClipboardRead::Later(Box::new(move || {+ dib_png(&dib).or_else(|| {+ let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;+ if raw::seq_num() != seq {+ return None;+ }+ rest.into_iter().find_map(|id| {+ let mut dib = vec![];+ raw::get_vec(id, &mut dib).ok()?;+ dib_png(&dib)+ })+ })+ }))); } TEXT => { raw::get_string(&mut data).ok()?;@@ -300,7 +328,7 @@ mod win { { data.truncate(end); }- (!data.is_empty()).then_some(Flavour { mime, data })+ (!data.is_empty()).then_some(ClipboardRead::Now(Flavour { mime, data })) } } @@ -370,22 +398,27 @@ mod portable { } fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {+ self.read_deferred(mimes)?.flavour()+ }++ /// arboard's image is read here and encoded as PNG by the work that comes back.+ fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> { let cb = self.cb().ok()?; if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {- return Some(f);+ return Some(ClipboardRead::Now(f)); } mimes.iter().find_map(|m| { #[cfg(target_os = "macos")] if let Some(f) = super::mac::read(m) {- return Some(f);+ return Some(ClipboardRead::Now(f)); } match *m {- TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),+ TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })), BITMAP => { let img = cb.get_image().ok()?;- let rgba =- image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;- png_flavour(&rgba)+ let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);+ let bytes = img.bytes.into_owned();+ Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?)))) } _ => None, }@@ -22,6 +22,7 @@ use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDe use vectorcraft_geom::Rect; pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};+pub use flavours::{ImageRead, read_image}; pub(crate) use resources::SwatchChoices; pub use resources::{ArtResources, art_resources}; @@ -413,12 +413,36 @@ fn flavours(s: &mut Session, _: &Value) -> Result<Value> { Ok(json!({ "flavours": s.clipboard_flavour_types() })) } +/// A bitmap read as `clipboard.importImage` reads its `dataBase64` ([`read_image`]), for+/// [`Session::import_image_read`].+pub struct ImageRead(fileio::RasterImage);++/// Read `bytes` as `clipboard.importImage` reads its `dataBase64`, on any thread: the formats it+/// stores as PNG are decoded and encoded here.+pub fn read_image(bytes: &[u8]) -> Result<ImageRead> {+ fileio::raster_image(bytes).map(ImageRead).map_err(|e| bad("clipboard.importImage", e.to_string()))+}++impl Session {+ /// `clipboard.importImage` with `p` (its `mime` and `center`) for an image [`read_image`] read+ /// (off the UI thread in the desktop app), which it loads instead of reading `dataBase64`.+ pub fn import_image_read(&mut self, read: ImageRead, p: &Value) -> Result<Value> {+ self.image_hand_off = Some(read);+ let r = self.execute("clipboard.importImage", p);+ self.image_hand_off = None;+ r+ }+}+ fn import_image(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "clipboard.importImage"; if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) { return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText"))); }- let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?;+ let ImageRead(img) = match s.image_hand_off.take() {+ Some(read) => read,+ None => read_image(&data_param(p, C)?)?,+ }; let (sx, sy) = pt_per_px(img.ppi); let (w, h) = (img.width, img.height); let image = ImageObject {@@ -1481,6 +1481,9 @@ pub struct Session { /// While [`Session::place_queue_read`] runs `file.place.queue`: the files read for it, which /// the command loads the place cursor with instead of reading them; `None` otherwise. pub(crate) queue_hand_off: Option<cmd::place::QueueRead>,+ /// While [`Session::import_image_read`] runs `clipboard.importImage`: the image read for it,+ /// which the command loads instead of reading `dataBase64`; `None` otherwise.+ pub(crate) image_hand_off: Option<cmd::clipboard::ImageRead>, /// Whether a click or drag of the place cursor asks the frontend to place its file /// ([`UiRequest::Place`]) instead of running `file.place`: the desktop app sets it to read the /// file off the UI thread. False: the file is placed at once.@@ -1610,6 +1613,7 @@ impl Session { batch_steps: None, place_hand_off: None, queue_hand_off: None,+ image_hand_off: None, place_cursor_requests: false, package_hand_off: None, screens_hand_off: None,@@ -150,6 +150,30 @@ fn import_image_then_paste_gives_an_image() { assert!(s.execute("clipboard.importImage", &json!({"dataBase64": "!!"})).is_err()); } +/// An image read apart from the session (off the UI thread in the desktop app) loads as+/// `clipboard.importImage` loads its bytes: the same reply and clipboard, and the same errors.+#[test]+fn an_image_read_apart_loads_as_import_image_loads_it() {+ let mut bmp = vec![];+ image::RgbImage::from_pixel(30, 20, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut bmp), image::ImageFormat::Bmp).unwrap();+ let (mut at_once, mut apart) = (session(), session());+ let p = json!({"mime": "image/bmp", "center": [100, 80]});+ let mut with_data = p.clone();+ with_data["dataBase64"] = json!(vectorcraft_format::base64_encode(&bmp));+ let r = run(&mut at_once, "clipboard.importImage", with_data);+ let read = crate::cmd::clipboard::read_image(&bmp).unwrap();+ assert_eq!(apart.import_image_read(read, &p).unwrap(), r);+ assert_eq!(apart.clipboard, at_once.clipboard);+ assert!(apart.image_hand_off.is_none());+ // Errors: bytes that aren't a picture, and a type that isn't a bitmap.+ let not_a_picture = at_once.execute("clipboard.importImage", &json!({"dataBase64": vectorcraft_format::base64_encode(b"hello")})).unwrap_err();+ assert_eq!(crate::cmd::clipboard::read_image(b"hello").err().map(|e| e.to_string()), Some(not_a_picture.to_string()));+ let read = crate::cmd::clipboard::read_image(&bmp).unwrap();+ let svg = apart.import_image_read(read, &json!({"mime": SVG})).unwrap_err();+ assert!(svg.to_string().contains("isn't a bitmap"), "{svg}");+ assert!(apart.image_hand_off.is_none() && apart.clipboard == at_once.clipboard, "nothing loaded");+}+ #[test] fn copied_svgz_files_paste_as_svg() { let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"40\"><rect width=\"40\" height=\"40\" fill=\"#ff0000\"/></svg>";@@ -4294,6 +4294,7 @@ axis Both И двете @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Неуспешно поставяне на {name}: {e} @msg Couldn't paste from the clipboard: {e} Неуспешно поставяне от клипборда: {e}+@msg Couldn't paste: the document it was pasted in was closed Неуспешно поставяне: документът, в който се поставяше, е затворен @msg Saved {path} with {_1} note(s): {first} {path} е запазен с {_1} бележка/и: {first} @msg Exported {what} with {_1} note(s): {first} {what} е експортиран с {_1} бележка/и: {first} @msg Exported {what} Експортирано: {what}@@ -4303,6 +4304,7 @@ axis Both И двете @msg Exporting {_1} Експортиране на {_1} @msg Packaging {_1} Пакетиране на {_1} @msg Placing {_1} Вмъкване на {_1}+@msg Pasting Поставяне @msg Saving recovery data for {_1} Записване на данни за възстановяване на {_1} @msg Discarded {_1} recovered document(s) Изхвърлени са {_1} възстановени документ(и) @msg Restored {_1} recovered document(s): save them to keep them Възстановени са {_1} документ(и): запазете ги, за да ги съхраните@@ -4701,6 +4701,7 @@ imageTrace Outline Obrys @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} {name} nelze umístit: {e} @msg Couldn't paste from the clipboard: {e} Nelze vložit ze schránky: {e}+@msg Couldn't paste: the document it was pasted in was closed Nelze vložit: dokument, do kterého se vkládalo, byl zavřen @msg Saved {path} with {_1} note(s): {first} Uloženo: {path}, poznámky ({_1}): {first} @msg Exported {what} with {_1} note(s): {first} Exportováno: {what}, poznámky ({_1}): {first} @msg Exported {what} Exportováno: {what}@@ -4710,6 +4711,7 @@ imageTrace Outline Obrys @msg Exporting {_1} Exportování {_1} @msg Packaging {_1} Balení {_1} @msg Placing {_1} Umísťování {_1}+@msg Pasting Vkládání @msg Saving recovery data for {_1} Ukládání dat pro obnovení pro {_1} @msg Discarded {_1} recovered document(s) Zahozeno obnovených dokumentů: {_1} @msg Restored {_1} recovered document(s): save them to keep them Obnoveno dokumentů: {_1}; pokud je chcete zachovat, uložte je@@ -4390,6 +4390,7 @@ imageTrace Outline Kontur @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Kunne ikke placere {name}: {e} @msg Couldn't paste from the clipboard: {e} Kunne ikke indsætte fra udklipsholderen: {e}+@msg Couldn't paste: the document it was pasted in was closed Kunne ikke indsætte: dokumentet, der blev indsat i, var lukket @msg Saved {path} with {_1} note(s): {first} Gemte {path} med {_1} note(r): {first} @msg Exported {what} with {_1} note(s): {first} Eksporterede {what} med {_1} note(r): {first} @msg Exported {what} Eksporterede {what}@@ -4399,6 +4400,7 @@ imageTrace Outline Kontur @msg Exporting {_1} Eksporterer {_1} @msg Packaging {_1} Pakker {_1} @msg Placing {_1} Placerer {_1}+@msg Pasting Indsætter @msg Saving recovery data for {_1} Gemmer gendannelsesdata for {_1} @msg Discarded {_1} recovered document(s) Forkastede {_1} gendannet(e) dokument(er) @msg Restored {_1} recovered document(s): save them to keep them Genvandt {_1} gendannet(e) dokument(er): gem dem for at beholde dem@@ -4324,6 +4324,7 @@ theme Appearance Erscheinungsbild @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} {name} konnte nicht platziert werden: {e} @msg Couldn't paste from the clipboard: {e} Aus der Zwischenablage konnte nicht eingefügt werden: {e}+@msg Couldn't paste: the document it was pasted in was closed Konnte nicht eingefügt werden: Das Dokument, in das eingefügt wurde, wurde geschlossen @msg Saved {path} with {_1} note(s): {first} {path} mit {_1} Hinweis(en) gespeichert: {first} @msg Exported {what} with {_1} note(s): {first} {what} mit {_1} Hinweis(en) exportiert: {first} @msg Exported {what} {what} exportiert@@ -4333,6 +4334,7 @@ theme Appearance Erscheinungsbild @msg Exporting {_1} {_1} wird exportiert @msg Packaging {_1} {_1} wird verpackt @msg Placing {_1} {_1} wird platziert+@msg Pasting Wird eingefügt @msg Saving recovery data for {_1} Wiederherstellungsdaten für {_1} werden gespeichert @msg Discarded {_1} recovered document(s) {_1} wiederhergestellte(s) Dokument(e) verworfen @msg Restored {_1} recovered document(s): save them to keep them {_1} wiederhergestellte(s) Dokument(e) geöffnet: Speichern Sie sie, um sie zu behalten@@ -4358,6 +4358,7 @@ theme Appearance Εμφάνιση @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Δεν ήταν δυνατή η τοποθέτηση του {name}: {e} @msg Couldn't paste from the clipboard: {e} Δεν ήταν δυνατή η επικόλληση από το πρόχειρο: {e}+@msg Couldn't paste: the document it was pasted in was closed Δεν ήταν δυνατή η επικόλληση: το έγγραφο στο οποίο γινόταν η επικόλληση έκλεισε @msg Saved {path} with {_1} note(s): {first} Αποθηκεύτηκε το {path} με {_1} σημειώσεις: {first} @msg Exported {what} with {_1} note(s): {first} Εξήχθη το {what} με {_1} σημειώσεις: {first} @msg Exported {what} Εξήχθη: {what}@@ -4367,6 +4368,7 @@ theme Appearance Εμφάνιση @msg Exporting {_1} Εξαγωγή {_1} @msg Packaging {_1} Συσκευασία {_1} @msg Placing {_1} Τοποθέτηση {_1}+@msg Pasting Επικόλληση @msg Saving recovery data for {_1} Αποθήκευση δεδομένων ανάκτησης για {_1} @msg Discarded {_1} recovered document(s) Απορρίφθηκαν {_1} ανακτημένα έγγραφα @msg Restored {_1} recovered document(s): save them to keep them Επαναφέρθηκαν {_1} ανακτημένα έγγραφα: αποθηκεύστε τα για να διατηρηθούν@@ -4400,6 +4400,7 @@ axis Both Ambos @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} No se pudo colocar {name}: {e} @msg Couldn't paste from the clipboard: {e} No se pudo pegar desde el portapapeles: {e}+@msg Couldn't paste: the document it was pasted in was closed No se pudo pegar: se cerró el documento en el que se estaba pegando @msg Saved {path} with {_1} note(s): {first} Se guardó {path} con {_1} nota(s): {first} @msg Exported {what} with {_1} note(s): {first} Se exportó {what} con {_1} nota(s): {first} @msg Exported {what} Se exportó {what}@@ -4409,6 +4410,7 @@ axis Both Ambos @msg Exporting {_1} Exportando {_1} @msg Packaging {_1} Empaquetando {_1} @msg Placing {_1} Colocando {_1}+@msg Pasting Pegando @msg Saving recovery data for {_1} Guardando datos de recuperación de {_1} @msg Discarded {_1} recovered document(s) Se descartaron {_1} documento(s) recuperado(s) @msg Restored {_1} recovered document(s): save them to keep them Se restauraron {_1} documento(s) recuperado(s): guárdalos para conservarlos@@ -4608,6 +4608,7 @@ imageTrace Outline Ääriviiva @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Kohdetta {name} ei voitu sijoittaa: {e} @msg Couldn't paste from the clipboard: {e} Leikepöydältä ei voitu liittää: {e}+@msg Couldn't paste: the document it was pasted in was closed Liittäminen ei onnistunut: asiakirja, johon liitettiin, on suljettu @msg Saved {path} with {_1} note(s): {first} Tallennettiin {path}, {_1} huomautus(ta): {first} @msg Exported {what} with {_1} note(s): {first} Vietiin {what}, {_1} huomautus(ta): {first} @msg Exported {what} Vietiin {what}@@ -4617,6 +4618,7 @@ imageTrace Outline Ääriviiva @msg Exporting {_1} Viedään {_1} @msg Packaging {_1} Paketoidaan {_1} @msg Placing {_1} Sijoitetaan {_1}+@msg Pasting Liitetään @msg Saving recovery data for {_1} Tallennetaan palautustietoja: {_1} @msg Discarded {_1} recovered document(s) Hylättiin {_1} palautettu(a) asiakirja(a) @msg Restored {_1} recovered document(s): save them to keep them Palautettiin {_1} asiakirja(a): tallenna ne säilyttääksesi ne@@ -4396,6 +4396,7 @@ axis Both Les deux @msg {path}: {e} {path} : {e} @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Impossible d’importer {name} : {e}+@msg Couldn't paste: the document it was pasted in was closed Impossible de coller, car le document de destination a été fermé @msg Couldn't paste from the clipboard: {e} Impossible de coller depuis le presse-papiers : {e} @msg Saved {path} with {_1} note(s): {first} {path} enregistré avec {_1} remarque(s) : {first} @msg Exported {what} with {_1} note(s): {first} {what} exporté avec {_1} remarque(s) : {first}@@ -4406,6 +4407,7 @@ axis Both Les deux @msg Exporting {_1} Exportation de {_1} @msg Packaging {_1} Assemblage de {_1} @msg Placing {_1} Importation de {_1}+@msg Pasting Collage @msg Saving recovery data for {_1} Enregistrement des données de récupération de {_1} @msg Discarded {_1} recovered document(s) {_1} document(s) récupéré(s) supprimé(s) @msg Restored {_1} recovered document(s): save them to keep them {_1} document(s) récupéré(s) restauré(s) : enregistrez-les pour les conserver@@ -4600,6 +4600,7 @@ imageTrace Outline Körvonal @msg Couldn't copy to the system clipboard: {e} Nem sikerült a rendszer vágólapjára másolni: {e} @msg Couldn't open {name}: {e} Nem sikerült megnyitni: {name}: {e} @msg Couldn't paste from the clipboard: {e} Nem sikerült beilleszteni a vágólapról: {e}+@msg Couldn't paste: the document it was pasted in was closed Nem sikerült beilleszteni: a dokumentumot, amelybe a beillesztés történt, bezárták @msg Couldn't place {name}: the document it was dropped on was closed Nem sikerült elhelyezni: {name}; a céldokumentumot bezárták @msg Couldn't place {name}: the document it was placed in was closed Nem sikerült elhelyezni: {name}; a dokumentumot, amelybe került volna, bezárták @msg Couldn't place {name}: {e} Nem sikerült elhelyezni: {name}: {e}@@ -4642,6 +4643,7 @@ imageTrace Outline Körvonal @msg Exporting {_1} Exportálás: {_1} @msg Packaging {_1} Csomagolás: {_1} @msg Placing {_1} Elhelyezés: {_1}+@msg Pasting Beillesztés @msg file version {0} is newer than this VectorCraft supports ({VERSION}) a fájl verziója újabb, mint amit ez a VectorCraft támogat: {0} (támogatott: {VERSION}) @msg Flatten Transparency: select objects to flatten Átlátszóság lapítása: jelöld ki a lapítandó objektumokat @msg Flatten Transparency: {e} Átlátszóság lapítása: {e}@@ -4390,6 +4390,7 @@ imageTrace Outline Kontur @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Tidak dapat menempatkan {name}: {e} @msg Couldn't paste from the clipboard: {e} Tidak dapat menempel dari papan klip: {e}+@msg Couldn't paste: the document it was pasted in was closed Tidak dapat menempel: dokumen tempatnya ditempel sudah ditutup @msg Saved {path} with {_1} note(s): {first} {path} disimpan dengan {_1} catatan: {first} @msg Exported {what} with {_1} note(s): {first} {what} diekspor dengan {_1} catatan: {first} @msg Exported {what} {what} diekspor@@ -4399,6 +4400,7 @@ imageTrace Outline Kontur @msg Exporting {_1} Mengekspor {_1} @msg Packaging {_1} Mengemas {_1} @msg Placing {_1} Menempatkan {_1}+@msg Pasting Menempel @msg Saving recovery data for {_1} Menyimpan data pemulihan untuk {_1} @msg Discarded {_1} recovered document(s) {_1} dokumen hasil pemulihan dibuang @msg Restored {_1} recovered document(s): save them to keep them {_1} dokumen hasil pemulihan dipulihkan: simpan agar tidak hilang@@ -4440,6 +4440,7 @@ axis Both Entrambi @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Impossibile inserire {name}: {e} @msg Couldn't paste from the clipboard: {e} Impossibile incollare dagli appunti: {e}+@msg Couldn't paste: the document it was pasted in was closed Impossibile incollare: il documento in cui si incollava è stato chiuso @msg Saved {path} with {_1} note(s): {first} Salvato {path} con {_1} nota/e: {first} @msg Exported {what} with {_1} note(s): {first} Esportato {what} con {_1} nota/e: {first} @msg Exported {what} Esportato {what}@@ -4449,6 +4450,7 @@ axis Both Entrambi @msg Exporting {_1} Esportazione di {_1} @msg Packaging {_1} Creazione del pacchetto {_1} @msg Placing {_1} Inserimento di {_1}+@msg Pasting Incollamento @msg Saving recovery data for {_1} Salvataggio dei dati di recupero per {_1} @msg Discarded {_1} recovered document(s) Documenti recuperati scartati: {_1} @msg Restored {_1} recovered document(s): save them to keep them Documenti recuperati ripristinati: {_1}. Salvali per conservarli@@ -4338,6 +4338,7 @@ swatch Bright Violet ビビッドバイオレット @msg Exporting {_1} {_1} を書き出し中 @msg Packaging {_1} {_1} をパッケージ中 @msg Placing {_1} {_1} を配置中+@msg Pasting 貼り付け中 @msg Exported {what} {what} を書き出しました @msg "{0}" is not a colour: write it as #rrggbb 「{0}」はカラーではありません: #rrggbb の形式で指定してください @msg could not decode image: {0} 画像をデコードできませんでした: {0}@@ -4547,6 +4548,7 @@ swatch Bright Violet ビビッドバイオレット @msg {_1} is reserved by the system {_1} はシステムで予約されています @msg Couldn't copy to the system clipboard: {e} システムのクリップボードにコピーできませんでした: {e} @msg Couldn't paste from the clipboard: {e} クリップボードから貼り付けられませんでした: {e}+@msg Couldn't paste: the document it was pasted in was closed 貼り付けられませんでした: 貼り付け先のドキュメントは閉じられています @msg nothing selected 何も選択されていません @msg the {tool} tool has no options {tool} ツールにはオプションがありません @msg unknown tool `{tool}` `{tool}` は不明なツールです@@ -4154,6 +4154,7 @@ font-technology Variable ცვლადი @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} {name}-ის განთავსება ვერ მოხერხდა: {e} @msg Couldn't paste from the clipboard: {e} გაცვლის ბუფერიდან ჩასმა ვერ მოხერხდა: {e}+@msg Couldn't paste: the document it was pasted in was closed ჩასმა ვერ მოხერხდა: დოკუმენტი, რომელშიც ის ჩაისმებოდა, დაიხურა @msg Saved {path} with {_1} note(s): {first} შენახულია {path}, შენიშვნები: {_1}: {first} @msg Exported {what} with {_1} note(s): {first} ექსპორტირებულია {what}, შენიშვნები: {_1}: {first} @msg Exported {what} ექსპორტირებულია {what}@@ -4163,6 +4164,7 @@ font-technology Variable ცვლადი @msg Exporting {_1} ექსპორტირდება: {_1} @msg Packaging {_1} იფუთება: {_1} @msg Placing {_1} თავსდება: {_1}+@msg Pasting ჩასმა @msg Saving recovery data for {_1} {_1}-ის აღდგენის მონაცემების შენახვა @msg Discarded {_1} recovered document(s) უარყოფილია აღდგენილი დოკუმენტები: {_1} @msg Restored {_1} recovered document(s): save them to keep them აღდგენილია დოკუმენტები: {_1}: შესანარჩუნებლად შეინახეთ ისინი@@ -4270,6 +4270,7 @@ theme Appearance 외관 @msg Exporting {_1} {_1} 내보내는 중 @msg Packaging {_1} {_1} 패키지 만드는 중 @msg Placing {_1} {_1} 가져오는 중+@msg Pasting 붙여넣는 중 @msg Exported {what} {what}을(를) 내보냈습니다 @msg "{0}" is not a colour: write it as #rrggbb "{0}"은(는) 색상이 아닙니다: #rrggbb 형식으로 입력하세요 @msg could not decode image: {0} 이미지를 디코딩할 수 없습니다: {0}@@ -4479,6 +4480,7 @@ theme Appearance 외관 @msg {_1} is reserved by the system {_1}은(는) 시스템에서 예약되어 있습니다 @msg Couldn't copy to the system clipboard: {e} 시스템 클립보드로 복사할 수 없습니다: {e} @msg Couldn't paste from the clipboard: {e} 클립보드에서 붙여넣을 수 없습니다: {e}+@msg Couldn't paste: the document it was pasted in was closed 붙여넣을 수 없습니다: 붙여넣던 문서가 닫혔습니다 @msg nothing selected 선택된 항목이 없습니다 @msg the {tool} tool has no options {tool} 도구에는 옵션이 없습니다 @msg unknown tool `{tool}` 알 수 없는 도구 `{tool}`@@ -4402,6 +4402,7 @@ imageTrace Outline Contour @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Kon {name} niet plaatsen: {e} @msg Couldn't paste from the clipboard: {e} Kon niet van het klembord plakken: {e}+@msg Couldn't paste: the document it was pasted in was closed Kon niet plakken: het document waarin werd geplakt, is gesloten @msg Saved {path} with {_1} note(s): {first} {path} opgeslagen met {_1} opmerking(en): {first} @msg Exported {what} with {_1} note(s): {first} {what} geëxporteerd met {_1} opmerking(en): {first} @msg Exported {what} {what} geëxporteerd@@ -4411,6 +4412,7 @@ imageTrace Outline Contour @msg Exporting {_1} Bezig met exporteren van {_1} @msg Packaging {_1} Bezig met verpakken van {_1} @msg Placing {_1} Bezig met plaatsen van {_1}+@msg Pasting Bezig met plakken @msg Saving recovery data for {_1} Bezig met opslaan van herstelgegevens voor {_1} @msg Discarded {_1} recovered document(s) {_1} hersteld(e) document(en) verworpen @msg Restored {_1} recovered document(s): save them to keep them {_1} hersteld(e) document(en) teruggezet: sla ze op om ze te behouden@@ -4526,6 +4526,7 @@ imageTrace Outline Kontur @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Nie udało się umieścić {name}: {e} @msg Couldn't paste from the clipboard: {e} Nie udało się wkleić ze schowka: {e}+@msg Couldn't paste: the document it was pasted in was closed Nie udało się wkleić: dokument, do którego wklejano, został zamknięty @msg Saved {path} with {_1} note(s): {first} Zapisano {path}, uwagi ({_1}): {first} @msg Exported {what} with {_1} note(s): {first} Wyeksportowano {what}, uwagi ({_1}): {first} @msg Exported {what} Wyeksportowano {what}@@ -4535,6 +4536,7 @@ imageTrace Outline Kontur @msg Exporting {_1} Eksportowanie {_1} @msg Packaging {_1} Pakowanie {_1} @msg Placing {_1} Umieszczanie {_1}+@msg Pasting Wklejanie @msg Saving recovery data for {_1} Zapisywanie danych odzyskiwania dla {_1} @msg Discarded {_1} recovered document(s) Odrzucone odzyskane dokumenty: {_1} @msg Restored {_1} recovered document(s): save them to keep them Przywrócone odzyskane dokumenty: {_1} — zapisz je, aby je zachować@@ -3971,6 +3971,7 @@ axis Both Ambos @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Não foi possível inserir {name}: {e} @msg Couldn't paste from the clipboard: {e} Não foi possível colar da área de transferência: {e}+@msg Couldn't paste: the document it was pasted in was closed Não foi possível colar: o documento em que estava sendo colado foi fechado @msg Saved {path} with {_1} note(s): {first} {path} salvo com {_1} observação(ões): {first} @msg Exported {what} with {_1} note(s): {first} {what} exportado com {_1} observação(ões): {first} @msg Exported {what} {what} exportado@@ -3980,6 +3981,7 @@ axis Both Ambos @msg Exporting {_1} Exportando {_1} @msg Packaging {_1} Empacotando {_1} @msg Placing {_1} Inserindo {_1}+@msg Pasting Colando @msg Saving recovery data for {_1} Salvando dados de recuperação de {_1} @msg Discarded {_1} recovered document(s) {_1} documento(s) recuperado(s) descartado(s) @msg Restored {_1} recovered document(s): save them to keep them {_1} documento(s) recuperado(s) restaurado(s): salve-os para mantê-los@@ -4436,6 +4436,7 @@ axis Both Оба @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Не удалось поместить {name}: {e} @msg Couldn't paste from the clipboard: {e} Не удалось вставить из буфера обмена: {e}+@msg Couldn't paste: the document it was pasted in was closed Не удалось вставить: документ, в который выполнялась вставка, был закрыт @msg Saved {path} with {_1} note(s): {first} Сохранено: {path} с примечаниями ({_1}) : {first} @msg Exported {what} with {_1} note(s): {first} Экспортировано: {what} с примечаниями ({_1}) : {first} @msg Exported {what} Экспортировано: {what}@@ -4445,6 +4446,7 @@ axis Both Оба @msg Exporting {_1} Экспорт {_1} @msg Packaging {_1} Упаковка {_1} @msg Placing {_1} Размещение {_1}+@msg Pasting Вставка @msg Saving recovery data for {_1} Сохранение данных восстановления для {_1} @msg Discarded {_1} recovered document(s) Отменено восстановленных документов: {_1} @msg Restored {_1} recovered document(s): save them to keep them Восстановлено документов: {_1}: сохраните их, чтобы оставить@@ -4309,6 +4309,7 @@ axis Both Båda @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Kunde inte placera {name}: {e} @msg Couldn't paste from the clipboard: {e} Kunde inte klistra in från urklippet: {e}+@msg Couldn't paste: the document it was pasted in was closed Kunde inte klistra in: dokumentet det klistrades in i har stängts @msg Saved {path} with {_1} note(s): {first} Sparade {path} med {_1} anteckning(ar): {first} @msg Exported {what} with {_1} note(s): {first} Exporterade {what} med {_1} anteckning(ar): {first} @msg Exported {what} Exporterade {what}@@ -4318,6 +4319,7 @@ axis Both Båda @msg Exporting {_1} Exporterar {_1} @msg Packaging {_1} Paketerar {_1} @msg Placing {_1} Placerar {_1}+@msg Pasting Klistrar in @msg Saving recovery data for {_1} Sparar återställningsdata för {_1} @msg Discarded {_1} recovered document(s) Kastade {_1} återställda dokument @msg Restored {_1} recovered document(s): save them to keep them Återställde {_1} återskapade dokument: spara dem för att behålla dem@@ -4155,6 +4155,7 @@ axis Both Her İkisi @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} {name} yerleştirilemedi: {e} @msg Couldn't paste from the clipboard: {e} Panodan yapıştırılamadı: {e}+@msg Couldn't paste: the document it was pasted in was closed Yapıştırılamadı: yapıştırıldığı belge kapatıldı @msg Saved {path} with {_1} note(s): {first} {path}, {_1} notlarıyla kaydedildi: {first} @msg Exported {what} with {_1} note(s): {first} {_1} notlarıyla birlikte dışa aktarılan {what}: {first} @msg Exported {what} Dışa aktarılan {what}@@ -4164,6 +4165,7 @@ axis Both Her İkisi @msg Exporting {_1} {_1}'yi dışa aktarma @msg Packaging {_1} {_1} paketleniyor @msg Placing {_1} {_1} yerleştiriliyor+@msg Pasting Yapıştırılıyor @msg Saving recovery data for {_1} {_1} için kurtarma verileri kaydediliyor @msg Discarded {_1} recovered document(s) Atılan {_1} kurtarılan belge(ler) @msg Restored {_1} recovered document(s): save them to keep them Geri yüklenen {_1} kurtarılan belge(ler): saklamak için bunları kaydedin@@ -4424,6 +4424,7 @@ axis Both Обидві @msg {status} ({e}) {status} ({e}) @msg Couldn't place {name}: {e} Не вдалося помістити {name}: {e} @msg Couldn't paste from the clipboard: {e} Не вдалося вставити з буфера обміну: {e}+@msg Couldn't paste: the document it was pasted in was closed Не вдалося вставити: документ, у який виконувалося вставлення, було закрито @msg Saved {path} with {_1} note(s): {first} Збережено: {path} з примітками ({_1}) : {first} @msg Exported {what} with {_1} note(s): {first} Експортовано: {what} з примітками ({_1}) : {first} @msg Exported {what} Експортовано: {what}@@ -4433,6 +4434,7 @@ axis Both Обидві @msg Exporting {_1} Експорт {_1} @msg Packaging {_1} Пакування {_1} @msg Placing {_1} Розміщення {_1}+@msg Pasting Вставлення @msg Saving recovery data for {_1} Збереження даних відновлення для {_1} @msg Discarded {_1} recovered document(s) Скасовано відновлених документів: {_1} @msg Restored {_1} recovered document(s): save them to keep them Відновлено документів: {_1}: збережіть їх, щоб залишити@@ -4248,6 +4248,7 @@ theme Appearance Giao diện @msg Couldn't copy to the system clipboard: {e} Không thể sao chép vào khay nhớ tạm hệ thống: {e} @msg Couldn't open {name}: {e} Không thể mở {name}: {e} @msg Couldn't paste from the clipboard: {e} Không thể dán từ khay nhớ tạm: {e}+@msg Couldn't paste: the document it was pasted in was closed Không thể dán: tài liệu mà nó được dán vào đã đóng @msg Couldn't place {name}: the document it was dropped on was closed Không thể đặt {name}: tài liệu mà nó được thả vào đã đóng @msg Couldn't place {name}: the document it was placed in was closed Không thể đặt {name}: tài liệu mà nó được đặt vào đã đóng @msg Couldn't place {name}: {e} Không thể đặt {name}: {e}@@ -4288,6 +4289,7 @@ theme Appearance Giao diện @msg Exporting {_1} Đang xuất {_1} @msg Packaging {_1} Đang đóng gói {_1} @msg Placing {_1} Đang đặt {_1}+@msg Pasting Đang dán @msg file version {0} is newer than this VectorCraft supports ({VERSION}) phiên bản tệp {0} mới hơn VectorCraft hỗ trợ ({VERSION}) @msg Flatten Transparency: select objects to flatten Dẹp phẳng độ trong suốt: hãy chọn đối tượng để dẹp phẳng @msg Flatten Transparency: {e} Dẹp phẳng độ trong suốt: {e}@@ -4070,6 +4070,7 @@ imageTrace Outline 轮廓 @msg Exporting {_1} 正在导出 {_1} @msg Packaging {_1} 正在打包 {_1} @msg Placing {_1} 正在置入 {_1}+@msg Pasting 正在粘贴 @msg Exported {what} 已导出 {what} @msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是颜色:请写为 #rrggbb @msg could not decode image: {0} 无法解码图像:{0}@@ -4271,6 +4272,7 @@ imageTrace Outline 轮廓 @msg {_1} is reserved by the system {_1} 已被系统保留 @msg Couldn't copy to the system clipboard: {e} 无法复制到系统剪贴板:{e} @msg Couldn't paste from the clipboard: {e} 无法从剪贴板粘贴:{e}+@msg Couldn't paste: the document it was pasted in was closed 无法粘贴:要粘贴到的文档已关闭 @msg nothing selected 未选择任何对象 @msg the {tool} tool has no options {tool} 工具没有选项 @msg unknown tool `{tool}` 未知工具 `{tool}`@@ -2033,6 +2033,7 @@ file-filter Templates 範本 @msg Exporting {_1} 正在匯出 {_1} @msg Packaging {_1} 正在封裝 {_1} @msg Placing {_1} 正在置入 {_1}+@msg Pasting 正在貼上 @msg Exported {what} 已匯出 {what} @msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是顏色:請寫成 #rrggbb @msg could not decode image: {0} 無法解碼影像:{0}@@ -2234,6 +2235,7 @@ file-filter Templates 範本 @msg {_1} is reserved by the system {_1} 已被系統保留 @msg Couldn't copy to the system clipboard: {e} 無法複製到系統剪貼簿:{e} @msg Couldn't paste from the clipboard: {e} 無法從剪貼簿貼上:{e}+@msg Couldn't paste: the document it was pasted in was closed 無法貼上:要貼上的文件已關閉 @msg nothing selected 未選取任何物件 @msg the {tool} tool has no options {tool} 工具沒有選項 @msg unknown tool `{tool}` 未知的工具 `{tool}`@@ -192,7 +192,7 @@ use vectorcraft_engine::{Session, ViewInfo}; pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket}; pub use state::{UiState, View};-pub use sysclip::{ClipboardProbeFactory, SystemClipboard};+pub use sysclip::{ClipboardProbeFactory, ClipboardRead, MakeFlavour, SystemClipboard}; /// A file type a dialog filters by: its label, which shows translated, and its extensions without /// the dot.@@ -1079,10 +1079,6 @@ impl VectorcraftApp { } } if id.starts_with("edit.paste") {- if let Err(e) = self.adopt_system_clipboard() {- self.ui.status = e.clone();- return Err(e);- } // Paste (also without formatting) goes to the centre of the view. if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") && ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none())@@ -1091,6 +1087,16 @@ impl VectorcraftApp { { p.insert("center".into(), serde_json::json!([c.x, c.y])); }+ // A Paste the user gives decodes a bitmap off the UI thread ([`placing::adopt_for_paste`]),+ // and runs with these params once it is decoded.+ match placing::adopt_for_paste(self, id, ¶ms) {+ Err(e) => {+ self.ui.status = e.clone();+ return Err(e);+ }+ Ok(Some(started)) => return Ok(started),+ Ok(None) => {}+ } if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) { return r; }@@ -3970,7 +3970,11 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { && p.as_object().is_some_and(|o| !o.contains_key("to")) && app.session.active().is_some_and(|st| st.selection.anchors.is_empty()); let p = if follows_align_to { crate::panels::align::with_align_to(app, p) } else { p };- if let Err(e) = app.run(id, p) {+ // A Paste the user gives decodes a bitmap off the UI thread ([`crate::placing::adopt_for_paste`]).+ app.placing.for_user = id.starts_with("edit.paste");+ let r = app.run(id, p);+ app.placing.for_user = false;+ if let Err(e) = r { app.status(e); } else if matches!(id, "object.pattern.make" | "object.pattern.edit") { app.ui.open_panel = Some("patternOptions".into());@@ -4,23 +4,120 @@ //! the document it was placed in once it is ready, as it would be at once: the same object, undo //! step, selection and errors. The files the Place dialog loads the place cursor with are read, and //! their thumbnails made, the same way, and so is the file each click or drag of the place cursor-//! places. Files are read and placed in the order they were started. `file.place` and-//! `file.place.queue` from MCP and scripts run at once, and so does every place on wasm (no-//! threads), with `VECTORCRAFT_SYNC_OPEN` set, and in unit tests that do not set-//! `off_the_ui_thread` ([`available`]).+//! places. A Paste the user gives decodes another app's bitmap on the same thread, and runs once it+//! is decoded ([`adopt_for_paste`]). Files are read and placed in the order they were started.+//! `file.place`, `file.place.queue` and `edit.paste*` from MCP and scripts run at once, and so does+//! every place and paste on wasm (no threads), with `VECTORCRAFT_SYNC_OPEN` set, and in unit tests+//! that do not set `off_the_ui_thread` ([`available`]). +use std::collections::HashMap; use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};+use std::sync::{Arc, Mutex}; use serde_json::{Value, json};+use vectorcraft_engine::cmd::clipboard::{Clipboard, Flavour, ImageRead, read_image}; use vectorcraft_engine::cmd::place::{PlaceRead, QueueRead}; use vectorcraft_engine::file_access::ScopeHandle; use crate::VectorcraftApp; -/// What was read: one file to place, or the files to load the place cursor with.+/// What was read: one file to place, the files to load the place cursor with, or a bitmap to paste. enum Read { Place(Box<PlaceRead>), Queue(QueueRead),+ Paste(Box<Paste>),+}++/// A paste whose bitmap was decoded off the UI thread ([`adopt_for_paste`]).+struct Paste {+ /// The paste command and its params.+ id: String,+ params: Value,+ /// What it pastes (`None`: a bitmap the host converts couldn't be converted, and the paste+ /// takes the internal clipboard as it was when the paste started, as at once).+ pasted: Option<Pasted>,+ /// The view's center when the paste started, which the pasted objects are centered on.+ center: Option<[f64; 2]>,+ /// The objects selected when the paste started, which Paste in Front and Paste in Back paste+ /// next to (`None`: the paste follows an earlier one of its chain, and takes the selection+ /// that one left, [`Chain::left`]).+ selection: Option<Vec<vectorcraft_doc::NodeId>>,+ /// The number of its chain in [`Placing::chains`].+ chain: u64,+ /// Where new art went when the paste started, which is where the paste goes.+ insertion: Insertion,+ /// The latest Copy or Cut when the paste started ([`crate::sysclip::Publishing::latest`]).+ copied: u64,+ /// The internal clipboard when the paste started, which it pastes when [`Self::pasted`] is+ /// `None`.+ clipboard: Clipboard,+}++/// Where new art goes in the active document: the current layer, the isolated group or object, and+/// the drawing mode with the object Draw Inside draws into.+#[derive(Clone, PartialEq)]+struct Insertion {+ layer: Option<vectorcraft_doc::NodeId>,+ isolation: Option<vectorcraft_doc::NodeId>,+ draw_mode: vectorcraft_engine::DrawMode,+ draw_inside: Option<vectorcraft_doc::NodeId>,+}++impl Insertion {+ /// Where new art goes in `app`'s active document now.+ fn of(app: &VectorcraftApp) -> Option<Self> {+ let st = app.session.doc().ok()?;+ Some(Self { layer: st.active_layer, isolation: st.isolation, draw_mode: app.session.draw_mode, draw_inside: app.session.draw_inside })+ }++ /// Make it where new art goes in `app`'s active document → where new art went before.+ fn set(self, app: &mut VectorcraftApp) -> Option<Self> {+ let st = app.session.doc_mut().ok()?;+ let (layer, isolation) = (std::mem::replace(&mut st.active_layer, self.layer), std::mem::replace(&mut st.isolation, self.isolation));+ let draw_mode = std::mem::replace(&mut app.session.draw_mode, self.draw_mode);+ let draw_inside = std::mem::replace(&mut app.session.draw_inside, self.draw_inside);+ Some(Self { layer, isolation, draw_mode, draw_inside })+ }++ /// Put back each part of `self` (where new art went before [`Self::set`] set `started`) that+ /// differs from `started`, what the user changed meanwhile; a part the paste itself changed+ /// stays as the paste left it.+ fn restore_changed(self, started: &Self, app: &mut VectorcraftApp) {+ if let Ok(st) = app.session.doc_mut() {+ if self.layer != started.layer {+ st.active_layer = self.layer;+ }+ if self.isolation != started.isolation {+ st.isolation = self.isolation;+ }+ }+ if self.draw_mode != started.draw_mode {+ app.session.draw_mode = self.draw_mode;+ }+ if self.draw_inside != started.draw_inside {+ app.session.draw_inside = self.draw_inside;+ }+ }+}++/// Pastes decoded off the UI thread that follow each other in one document: each was given while the+/// one before waited, with the selection and the insertion context that one started with or, once+/// it ran, left.+struct Chain {+ /// The selection and the insertion context a paste given now must have to join the chain.+ context: (Vec<vectorcraft_doc::NodeId>, Insertion),+ /// The selection the chain's latest paste left, which the next one takes.+ left: Option<Vec<vectorcraft_doc::NodeId>>,+ /// How many of its pastes wait.+ waiting: usize,+}++/// What a paste whose bitmap was decoded off the UI thread pastes.+enum Pasted {+ /// The bitmap's type and the image read from it.+ Image(&'static str, Result<ImageRead, String>),+ /// Another flavour: the text that names the picture, when the picture couldn't be made.+ Flavour(Flavour), } /// Files being read for Place.@@ -34,6 +131,10 @@ pub struct Pending { /// Whether the file was dropped on the canvas (its messages say so, as /// [`crate::place::drop_files`]'s do). dropped: bool,+ /// Whether it is a paste's bitmap ([`adopt_for_paste`]) rather than a file.+ paste: bool,+ /// The number of the paste's chain ([`Placing::chains`]).+ chain: Option<u64>, done: Receiver<Result<Read, String>>, } @@ -46,6 +147,17 @@ pub struct Placing { pub pending: Vec<Pending>, /// Set by unit tests whose app reads the files the user places off the UI thread. pub(crate) off_the_ui_thread: bool,+ /// Set while [`crate::menus::invoke`] runs a Paste the user gave.+ pub(crate) for_user: bool,+ /// Set while a paste whose bitmap was converted off the UI thread runs: it takes the internal+ /// clipboard as it is.+ adopted: bool,+ /// The chains of pastes decoded off the UI thread that have pastes waiting, by number.+ chains: HashMap<u64, Chain>,+ /// For each document, the number of its latest chain, which a paste given there may join.+ latest_chain: HashMap<u64, u64>,+ /// The number the next chain takes.+ next_chain: u64, /// The thread that reads the files, one at a time in the order they were started: started with /// the first read, it ends when the app does. reader: Option<Sender<Job>>,@@ -97,6 +209,71 @@ pub fn start_queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> } } +/// Before paste command `id` with `p`: what another app put on the system clipboard replaces the+/// internal clipboard ([`VectorcraftApp::adopt_system_clipboard`]) → `None`, and the paste runs at+/// once. For a Paste the user gives ([`crate::menus::invoke`]) where placed files are read off the+/// UI thread ([`available`]), the bitmap's bytes are copied off the system clipboard at once and+/// decoded on the thread that reads placed files, after the files started before it →+/// `{background: true, status}`: a bitmap the host converts to PNG is converted there+/// ([`crate::SystemClipboard::read_deferred`]), and the bitmap is read as `clipboard.importImage`+/// reads it ([`read_image`]). The paste runs once it is decoded, in the document it was given in,+/// as it would at once ([`poll`]). When no thread could start, the bitmap is read at once.+pub fn adopt_for_paste(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<Option<Value>, String> {+ if std::mem::take(&mut app.placing.adopted) {+ app.clipboard_image_request = None;+ return Ok(None);+ }+ if !(app.placing.for_user && available(app)) {+ return app.adopt_system_clipboard().map(|()| None);+ }+ let center = app.view().map(|v| [v.center.x, v.center.y]);+ let Some(make) = app.adopt_system_clipboard_or_defer(true)? else { return Ok(None) };+ let copied = app.clipboard_publishing.latest();+ let (doc, objects) = app.session.doc().map(|st| (st.uid, st.selection.objects.clone())).map_err(|e| e.to_string())?;+ let insertion = Insertion::of(app).ok_or("no document")?;+ // A paste given while one of the document's latest chain waits, with the selection and the+ // insertion context that chain expects, joins it: it goes next to what the paste before it+ // leaves selected, as at once. Otherwise it starts a chain.+ let context = (objects, insertion.clone());+ let joins =+ app.placing.latest_chain.get(&doc).copied().filter(|c| app.placing.chains.get(c).is_some_and(|ch| ch.waiting > 0 && ch.context == context));+ let (chain, selection) = match joins {+ Some(chain) => (chain, None),+ None => (app.placing.next_chain, Some(context.0.clone())),+ };+ let clipboard = app.session.clipboard.clone();+ let (id, params) = (id.to_string(), p.clone());+ // The work stays here when no thread could start.+ let slot = Arc::new(Mutex::new(Some(make)));+ let work = slot.clone();+ let started = spawn_labeled(app, "clipboard".into(), "Pasting".into(), false, Some(chain), move || {+ let make = work.lock().ok().and_then(|mut m| m.take());+ let pasted = make.and_then(|m| m()).map(|f| {+ if crate::sysclip::imports_as_image(f.mime) {+ Pasted::Image(f.mime, read_image(&f.data).map_err(|e| e.to_string()))+ } else {+ Pasted::Flavour(f)+ }+ });+ Ok(Read::Paste(Box::new(Paste { id, params, pasted, center, selection, chain, insertion, copied, clipboard })))+ })?;+ if started.is_some() {+ if joins.is_none() {+ app.placing.next_chain += 1;+ app.placing.chains.insert(chain, Chain { context, left: None, waiting: 0 });+ app.placing.latest_chain.insert(doc, chain);+ }+ if let Some(ch) = app.placing.chains.get_mut(&chain) {+ ch.waiting += 1;+ }+ return Ok(started);+ }+ match slot.lock().ok().and_then(|mut m| m.take()).and_then(|m| m()) {+ Some(f) => app.adopt_flavour(&f, center).map(|()| None),+ None => Ok(None),+ }+}+ /// Run `read` for the active document on the thread that reads the files, after the reads started /// before it (so a batch of large pictures is decoded one at a time, as at once) → /// `{background: true, status}`, or `None` when that thread could not start.@@ -106,6 +283,18 @@ fn spawn( shown: String, dropped: bool, read: impl FnOnce() -> Result<Read, String> + Send + 'static,+) -> Result<Option<Value>, String> {+ spawn_labeled(app, name, format!("Placing {shown}"), dropped, None, read)+}++/// [`spawn`] with the status bar's `label`; `chain` for a paste's bitmap, its chain's number.+fn spawn_labeled(+ app: &mut VectorcraftApp,+ name: String,+ label: String,+ dropped: bool,+ chain: Option<u64>,+ read: impl FnOnce() -> Result<Read, String> + Send + 'static, ) -> Result<Option<Value>, String> { let doc = app.session.doc().map_err(|e| e.to_string())?.uid; let (tx, done) = channel();@@ -145,8 +334,7 @@ fn spawn( let _ = reader.send(job); app.placing.reader = Some(reader); }- let label = format!("Placing {shown}");- app.placing.pending.push(Pending { name, label: label.clone(), doc, dropped, done });+ app.placing.pending.push(Pending { name, label: label.clone(), doc, dropped, paste: chain.is_some(), chain, done }); let status = format!("{label}…"); app.status(status.clone()); Ok(Some(json!({ "background": true, "status": status })))@@ -178,6 +366,16 @@ pub fn poll(app: &mut VectorcraftApp) { app.ui.status.clear(); } finish(app, &p, r);+ // A chain none of whose pastes waits any more is dropped, whether its pastes ran or not.+ if let Some(chain) = p.chain+ && let Some(ch) = app.placing.chains.get_mut(&chain)+ {+ ch.waiting = ch.waiting.saturating_sub(1);+ if ch.waiting == 0 {+ app.placing.chains.remove(&chain);+ app.placing.latest_chain.retain(|_, c| *c != chain);+ }+ } } } @@ -187,7 +385,9 @@ pub fn poll(app: &mut VectorcraftApp) { fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result<Read, String>) { let Some(i) = app.session.documents().iter().position(|st| st.uid == p.doc) else { let name = &p.name;- if p.dropped {+ if p.paste {+ app.status("Couldn't paste: the document it was pasted in was closed");+ } else if p.dropped { app.status(format!("Couldn't place {name}: the document it was dropped on was closed")); } else { app.status(format!("Couldn't place {name}: the document it was placed in was closed"));@@ -198,6 +398,8 @@ fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result<Read, String>) { let r = match r { Ok(Read::Place(read)) => app.session.place_read(*read).map(|_| ()).map_err(|e| e.to_string()), Ok(Read::Queue(read)) => app.session.place_queue_read(read).map(|r| crate::place::show_thumbnails(app, &r)).map_err(|e| e.to_string()),+ Ok(Read::Paste(paste)) => return finish_paste(app, *paste),+ Err(e) if p.paste => return app.status(format!("Couldn't paste from the clipboard: {e}")), Err(e) => Err(e), }; app.sync_views();@@ -209,11 +411,75 @@ fn finish(app: &mut VectorcraftApp, p: &Pending, r: Result<Read, String>) { } } +/// Run a paste whose bitmap was decoded off the UI thread, in its document, which is the active one:+/// the image replaces the internal clipboard, the objects selected when the paste started are+/// selected again, and the paste command runs as it runs at once ([`VectorcraftApp::run`]), into+/// the layer that was current when it started, with its errors in the status bar. A Copy or Cut+/// made since the paste started keeps the internal clipboard it made.+fn finish_paste(app: &mut VectorcraftApp, paste: Paste) {+ let kept = (app.clipboard_publishing.latest() != paste.copied).then(|| app.session.clipboard.clone());+ let adopted = match paste.pasted {+ Some(Pasted::Image(mime, read)) => app.adopt_image_read(mime, paste.center, read),+ Some(Pasted::Flavour(f)) => app.adopt_flavour(&f, paste.center),+ None => {+ app.session.clipboard = paste.clipboard;+ Ok(())+ }+ };+ let chain = paste.chain;+ let selection = paste.selection.or_else(|| app.placing.chains.get_mut(&chain).and_then(|ch| ch.left.take()));+ let left = match adopted {+ Err(e) => {+ app.status(e);+ selection+ }+ Ok(()) => {+ // Where new art goes now, before a selection changes the current layer.+ let current = Insertion::of(app);+ // The objects selected when the paste started (or that the paste it follows left) are+ // selected again (those that are left), as `Session::place_read` selects the object+ // Replace swaps.+ let now = app.session.doc().map(|st| st.selection.objects.clone()).unwrap_or_default();+ if let Some(selection) = selection.filter(|s| *s != now) {+ let left: Vec<u64> =+ selection.iter().filter(|id| app.session.doc().is_ok_and(|st| st.doc.node(**id).is_some())).map(|id| id.0).collect();+ let _ = app.session.execute("select.set", &json!({ "ids": left }));+ }+ // The paste goes where new art went when it started (the current layer, the isolated+ // group, the object Draw Inside draws into), and what the user changed meanwhile is+ // kept.+ paste.insertion.clone().set(app);+ app.placing.adopted = true;+ // `run` shows an error in the status bar.+ let _ = app.run(&paste.id, paste.params);+ // Also when the paste did not reach the clipboard (the Type tool took it).+ app.placing.adopted = false;+ if let Some(current) = current {+ current.restore_changed(&paste.insertion, app);+ }+ app.session.doc().ok().map(|st| st.selection.objects.clone())+ }+ };+ // The chain's next paste takes the selection this one left, and a paste given with it and the+ // insertion context joins the chain while a paste of it waits ([`poll`] drops the chain).+ let context = app.session.doc().ok().map(|st| st.selection.objects.clone()).zip(Insertion::of(app));+ if let Some(ch) = app.placing.chains.get_mut(&chain) {+ ch.left = left;+ if let Some(context) = context {+ ch.context = context;+ }+ }+ if let Some(kept) = kept {+ app.session.clipboard = kept;+ }+}+ #[cfg(test)] mod tests { use image::ImageFormat::{Bmp, Png, Tiff}; use vectorcraft_doc::{Document, NodeId}; use vectorcraft_engine::Session;+ use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PNG, TEXT}; use super::*; use crate::Services;@@ -324,6 +590,427 @@ mod tests { } } + /// Another app's bitmap on the system clipboard, and the text beside it, read as the desktop+ /// hosts read them: with `converts`, the bitmap as the work that makes its flavour+ /// ([`crate::ClipboardRead::Later`], `None`: it can't be made), which notes the thread it runs+ /// on in `thread`. The work of each read waits for its entry of `holds` to send, when it has+ /// one.+ struct OtherApps {+ bitmap: Option<Flavour>,+ text: Option<Flavour>,+ converts: bool,+ thread: Arc<Mutex<Option<String>>>,+ holds: Vec<Option<std::sync::mpsc::Receiver<()>>>,+ }++ impl crate::SystemClipboard for OtherApps {+ fn write(&mut self, _: &[Flavour]) -> Result<(), String> {+ Ok(())+ }+ fn holds_ours(&mut self) -> bool {+ false+ }+ fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {+ self.read_deferred(mimes)?.flavour()+ }+ fn has(&mut self, mimes: &[&'static str]) -> bool {+ mimes.contains(&BITMAP) || (mimes.contains(&TEXT) && self.text.is_some())+ }+ fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<crate::ClipboardRead> {+ if mimes.contains(&TEXT)+ && let Some(text) = &self.text+ {+ return Some(crate::ClipboardRead::Now(text.clone()));+ }+ if !mimes.contains(&BITMAP) {+ return None;+ }+ let (bitmap, thread) = (self.bitmap.clone(), self.thread.clone());+ if !self.converts {+ return bitmap.map(crate::ClipboardRead::Now);+ }+ let hold = if self.holds.is_empty() { None } else { self.holds.remove(0) };+ Some(crate::ClipboardRead::Later(Box::new(move || {+ if let Some(hold) = hold {+ let _ = hold.recv_timeout(std::time::Duration::from_secs(10));+ }+ *thread.lock().unwrap() = std::thread::current().name().map(str::to_string);+ bitmap+ })))+ }+ }++ /// [`app`] with `bitmap` and `text` on the system clipboard ([`OtherApps`]), the view centered+ /// on (250, 180) → the app and the thread the bitmap was converted on.+ fn pasting_app(background: bool, bitmap: Option<Flavour>, text: Option<&str>, converts: bool) -> (VectorcraftApp, Arc<Mutex<Option<String>>>) {+ let thread = Arc::new(Mutex::new(None));+ let mut app = app(background);+ let text = text.map(|t| Flavour { mime: TEXT, data: t.as_bytes().to_vec() });+ app.services.system_clipboard = Some(Box::new(OtherApps { bitmap, text, converts, thread: thread.clone(), holds: vec![] }));+ let v = app.view_mut().unwrap();+ (v.center, v.fitted) = (vectorcraft_geom::Point::new(250.0, 180.0), true);+ (app, thread)+ }++ /// A Paste the user gives decodes another app's bitmap off the UI thread: a BMP as+ /// `clipboard.importImage` reads it, and a bitmap the host converts to PNG (Windows'+ /// device-independent bitmap, Linux's image) converted there too. Until then the document is+ /// unchanged, and the status bar and the busy cursor show the paste; then it is pasted as at+ /// once, where the view was centered when the paste was given: the same object, selection, undo+ /// step and internal clipboard. `edit.paste` from MCP and scripts reads it at once.+ #[test]+ fn a_bitmap_the_user_pastes_is_read_off_the_ui_thread() {+ let bmp = Flavour { mime: "image/bmp", data: picture(Bmp, 300, 200) };+ let png = Flavour { mime: PNG, data: picture(Png, 300, 200) };+ for (bitmap, converts) in [(bmp, false), (png, true)] {+ for id in ["edit.paste", "edit.pasteInPlace"] {+ let (mut at_once, _) = pasting_app(false, Some(bitmap.clone()), None, converts);+ crate::menus::invoke(&mut at_once, id, json!({}));+ let (mut app, _) = pasting_app(true, Some(bitmap.clone()), None, converts);+ crate::menus::invoke(&mut app, id, json!({}));+ // The view moves before the bitmap is read.+ app.view_mut().unwrap().center = vectorcraft_geom::Point::new(40.0, 30.0);+ placed(&mut app);+ assert!(state(&app) == state(&at_once), "{} {id}: pasted where the view was", bitmap.mime);+ }+ let (mut at_once, _) = pasting_app(false, Some(bitmap.clone()), None, converts);+ crate::menus::invoke(&mut at_once, "edit.paste", json!({}));+ let (mut app, thread) = pasting_app(true, Some(bitmap.clone()), None, converts);+ let before = state(&app);+ app.placing.for_user = true;+ let r = app.run("edit.paste", json!({}));+ app.placing.for_user = false;+ assert_eq!(r, Ok(json!({"background": true, "status": "Pasting…"})), "{}", bitmap.mime);+ assert_eq!(app.ui.status, "Pasting…");+ assert!(crate::opening::long_operation_busy(&app));+ assert!(state(&app) == before, "{}: nothing is pasted until the bitmap is read", bitmap.mime);+ placed(&mut app);+ assert!(state(&app) == state(&at_once), "{}: pasted as at once", bitmap.mime);+ assert!(app.session.clipboard == at_once.session.clipboard, "{}", bitmap.mime);+ assert!(app.ui.status.is_empty(), "{}", app.ui.status);+ if converts {+ assert_eq!(thread.lock().unwrap().as_deref(), Some("vectorcraft-place"));+ }+ // From the menu or the keys too.+ crate::menus::invoke(&mut app, "edit.paste", json!({}));+ assert_eq!(app.placing.pending.len(), 1);+ placed(&mut app);+ // MCP and scripts: at once.+ let r = app.run("edit.paste", json!({})).unwrap();+ assert!(r["ids"].is_array() && app.placing.pending.is_empty(), "{r}");+ assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1);+ }+ }++ /// A bitmap that can't be read reports as at once and pastes nothing; one the host can't+ /// convert pastes the internal clipboard as it is, or the address beside it as text, as at+ /// once.+ #[test]+ fn a_pasted_bitmap_that_cant_be_read_off_the_ui_thread_reports_as_at_once() {+ let url = "https://example.com/images/cat.png";+ let cases = [(Some(Flavour { mime: PNG, data: b"not a picture".to_vec() }), None, false), (None, None, true), (None, Some(url), true)];+ for (bitmap, text, converts) in cases {+ let (mut at_once, _) = pasting_app(false, bitmap.clone(), text, converts);+ let (mut app, _) = pasting_app(true, bitmap, text, converts);+ for app in [&mut at_once, &mut app] {+ app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();+ app.run("edit.copy", json!({})).unwrap();+ crate::menus::invoke(app, "edit.paste", json!({}));+ assert_eq!(app.placing.pending.len(), usize::from(app.placing.off_the_ui_thread));+ placed(app);+ }+ assert_eq!(app.ui.status, at_once.ui.status);+ assert!(state(&app) == state(&at_once), "text: {text:?}, converts: {converts}");+ let st = app.session.doc().unwrap();+ let pasted = st.selection.objects.first().and_then(|id| st.doc.node(*id)).map(|n| &n.kind);+ match (text, converts) {+ (Some(_), _) => assert!(matches!(pasted, Some(vectorcraft_doc::NodeKind::Text(_))), "the address is pasted as text"),+ (None, true) => assert!(matches!(pasted, Some(vectorcraft_doc::NodeKind::Path { .. })), "the copied square is pasted"),+ (None, false) => assert!(app.ui.status.starts_with("Couldn't paste from the clipboard: "), "{}", app.ui.status),+ }+ }+ }++ /// A Copy made while a pasted bitmap is read keeps the internal clipboard it made, and a paste+ /// whose document was closed meanwhile pastes nothing, with a message.+ #[test]+ fn a_copy_or_a_close_while_a_pasted_bitmap_is_read() {+ let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });+ let (mut app, _) = pasting_app(true, bitmap.clone(), None, true);+ crate::menus::invoke(&mut app, "edit.paste", json!({}));+ app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();+ app.run("edit.copy", json!({})).unwrap();+ let copied = app.session.clipboard.clone();+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let pasted = st.doc.node(st.selection.objects[0]).unwrap();+ assert!(matches!(pasted.kind, vectorcraft_doc::NodeKind::Image(_)), "the bitmap is pasted");+ assert!(app.session.clipboard == copied, "the copy stays on the internal clipboard");+ // A bitmap that can't be converted pastes the internal clipboard as it was when the paste+ // was given, and the later copy stays on it.+ let (mut app, _) = pasting_app(true, None, None, true);+ let square = |app: &mut VectorcraftApp, x: u32| {+ app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap();+ app.run("edit.copy", json!({})).unwrap();+ };+ square(&mut app, 10);+ let first = app.session.clipboard.clone();+ crate::menus::invoke(&mut app, "edit.pasteInPlace", json!({}));+ square(&mut app, 200);+ let second = app.session.clipboard.clone();+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let pasted = st.doc.node(st.selection.objects[0]).and_then(|n| n.geometric_bounds()).map(|b| b.x0);+ assert!(first != second && pasted == Some(10.0), "the first copy is pasted: {pasted:?}");+ assert!(app.session.clipboard == second, "the later copy stays on the internal clipboard");+ let (mut app, _) = pasting_app(true, bitmap, None, true);+ app.run("file.new", json!({"width": 100, "height": 100})).unwrap();+ crate::menus::invoke(&mut app, "edit.paste", json!({}));+ app.session.execute("file.close", &json!({})).unwrap();+ let left = state(&app);+ placed(&mut app);+ assert_eq!(app.ui.status, "Couldn't paste: the document it was pasted in was closed");+ assert!(state(&app) == left, "the other document is unchanged");+ assert!(app.placing.chains.is_empty() && app.placing.latest_chain.is_empty(), "no paste waits");+ }++ /// Paste in Front and Paste in Back put a bitmap decoded off the UI thread next to the objects+ /// selected when the paste was given, as at once, when another object was selected meanwhile;+ /// a second one given before the first is pasted goes next to the first, as at once.+ #[test]+ fn a_paste_in_front_read_off_the_ui_thread_keeps_its_place() {+ let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });+ for id in ["edit.pasteInFront", "edit.pasteInBack"] {+ for twice in [false, true] {+ let (mut at_once, _) = pasting_app(false, bitmap.clone(), None, true);+ let (mut app, _) = pasting_app(true, bitmap.clone(), None, true);+ for app in [&mut at_once, &mut app] {+ let rect = |app: &mut VectorcraftApp, x: u32| {+ app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone()+ };+ let (a, b, c) = (rect(app, 10), rect(app, 40), rect(app, 70));+ app.run("select.set", json!({"ids": [b]})).unwrap();+ crate::menus::invoke(app, id, json!({}));+ if twice {+ crate::menus::invoke(app, id, json!({}));+ } else {+ let other = if id.ends_with("Front") { a } else { c };+ app.run("select.set", json!({"ids": [other]})).unwrap();+ }+ placed(app);+ }+ // Each object in paint order: `None` for a square, the paste order for a picture.+ let order = |app: &VectorcraftApp| {+ let st = app.session.doc().unwrap();+ let children = st.doc.layers[0].children().unwrap();+ let mut pictures: Vec<NodeId> =+ children.iter().filter(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))).map(|n| n.id).collect();+ pictures.sort();+ children.iter().map(|n| pictures.iter().position(|p| *p == n.id)).collect::<Vec<_>>()+ };+ assert_eq!(order(&app), order(&at_once), "{id}, twice: {twice}");+ }+ }+ }++ /// A second Paste in Front given before the first is pasted goes next to the first one's+ /// picture, also when another object was selected between the two pastes' ends, and a third+ /// given after the first was pasted, while the second waits, goes next to the second's.+ #[test]+ fn a_second_paste_in_front_follows_the_first() {+ for third in [false, true] {+ let (mut app, _) = pasting_app(true, None, None, true);+ let (go, hold) = channel();+ let thread = Arc::new(Mutex::new(None));+ let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });+ app.services.system_clipboard = Some(Box::new(OtherApps { bitmap, text: None, converts: true, thread, holds: vec![None, Some(hold)] }));+ let rect = |app: &mut VectorcraftApp, x: u32| {+ app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone()+ };+ let (a, b) = (rect(&mut app, 10), rect(&mut app, 40));+ app.run("select.set", json!({"ids": [b]})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ let t0 = std::time::Instant::now();+ while app.placing.pending.len() > 1 && t0.elapsed().as_secs() < 60 {+ poll(&mut app);+ std::thread::yield_now();+ }+ assert_eq!(app.placing.pending.len(), 1, "the first paste ran");+ if third {+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ } else {+ app.run("select.set", json!({"ids": [a]})).unwrap();+ }+ let _ = go.send(());+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let kinds: Vec<Option<NodeId>> =+ st.doc.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)).then_some(n.id)).collect();+ let pictures: Vec<NodeId> = kinds.iter().flatten().copied().collect();+ assert!(pictures.len() == 2 + usize::from(third) && pictures.is_sorted(), "each picture is above the one before: {kinds:?}");+ assert_eq!(kinds.iter().position(Option::is_some), Some(2), "the pictures are above the square they were pasted on: {kinds:?}");+ }+ }++ /// A paste given beside the picture an independent paste left, while another paste waits, goes+ /// beside that picture.+ #[test]+ fn a_paste_beside_a_finished_picture_does_not_follow_another_chain() {+ let (mut app, _) = pasting_app(true, None, None, true);+ let (go, hold) = channel();+ let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });+ let clip = OtherApps { bitmap, text: None, converts: true, thread: Arc::new(Mutex::new(None)), holds: vec![None, Some(hold)] };+ app.services.system_clipboard = Some(Box::new(clip));+ let rect =+ |app: &mut VectorcraftApp, x: u32| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();+ let (x, y) = (rect(&mut app, 10), rect(&mut app, 40));+ app.run("select.set", json!({"ids": [x]})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ app.run("select.set", json!({"ids": [y]})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ let t0 = std::time::Instant::now();+ while app.placing.pending.len() > 1 && t0.elapsed().as_secs() < 60 {+ poll(&mut app);+ std::thread::yield_now();+ }+ assert_eq!(app.placing.pending.len(), 1, "the first paste ran");+ // The first picture is selected.+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ let _ = go.send(());+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let ids: Vec<Option<NodeId>> =+ st.doc.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)).then_some(n.id)).collect();+ let last = ids.iter().flatten().max().copied();+ assert_eq!(ids.iter().position(|id| *id == last && last.is_some()), Some(2), "the third picture is above the first: {ids:?}");+ }++ /// Pastes that follow each other in one document follow each other there, also when a paste in+ /// another document runs between them.+ #[test]+ fn pastes_in_two_documents_follow_their_own() {+ let (mut app, _) = pasting_app(true, None, None, true);+ let (go, hold) = channel();+ let bitmap = Some(Flavour { mime: PNG, data: picture(Png, 60, 40) });+ let clip = OtherApps { bitmap, text: None, converts: true, thread: Arc::new(Mutex::new(None)), holds: vec![None, Some(hold)] };+ app.services.system_clipboard = Some(Box::new(clip));+ let rect =+ |app: &mut VectorcraftApp, x: u32| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();+ let (_, b) = (rect(&mut app, 10), rect(&mut app, 40));+ app.run("select.set", json!({"ids": [b]})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ let t0 = std::time::Instant::now();+ while app.placing.pending.len() > 1 && t0.elapsed().as_secs() < 60 {+ poll(&mut app);+ std::thread::yield_now();+ }+ assert_eq!(app.placing.pending.len(), 1, "the first paste ran");+ // A paste in another document, then one more in the first.+ app.run("file.new", json!({"width": 400, "height": 300})).unwrap();+ let x = rect(&mut app, 10);+ app.run("select.set", json!({"ids": [x]})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ app.session.set_active(0);+ app.sync_views();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ let _ = go.send(());+ placed(&mut app);+ let st = &app.session.documents()[0];+ let kinds: Vec<Option<NodeId>> =+ st.doc.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)).then_some(n.id)).collect();+ let pictures: Vec<NodeId> = kinds.iter().flatten().copied().collect();+ assert!(pictures.len() == 3 && pictures.is_sorted(), "each picture is above the one before: {kinds:?}");+ assert_eq!(kinds.iter().position(Option::is_some), Some(2), "the pictures are above the square they were pasted on: {kinds:?}");+ }++ /// In Draw Inside in an isolated path, a bitmap decoded off the UI thread is pasted inside the path,+ /// whose new clipping group stays isolated, when the user went back to Draw Normal meanwhile.+ #[test]+ fn a_bitmap_pasted_inside_an_isolated_path_stays_isolated() {+ let (mut app, _) = pasting_app(true, Some(Flavour { mime: PNG, data: picture(Png, 60, 40) }), None, true);+ let path = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 200, "height": 200})).unwrap()["id"].clone();+ app.run("object.isolate", json!({"id": path})).unwrap();+ app.run("select.set", json!({"ids": [path]})).unwrap();+ app.run("view.drawMode", json!({"mode": "inside"})).unwrap();+ crate::menus::invoke(&mut app, "edit.paste", json!({}));+ app.run("view.drawMode", json!({"mode": "normal"})).unwrap();+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let pasted = st.selection.objects[0];+ let group = st.doc.parent_of(pasted);+ assert!(+ group.is_some_and(|g| st.doc.node(g).is_some_and(|n| n.children().is_some_and(|c| c.iter().any(|n| n.id.0 == path.as_u64().unwrap())))),+ "pasted inside the path"+ );+ assert_eq!(st.isolation, group, "the clipping group is isolated");+ assert_eq!(app.session.draw_mode, vectorcraft_engine::DrawMode::Normal, "Draw Normal stays");+ }++ /// A bitmap decoded off the UI thread is pasted into the layer that was current when the paste+ /// was given, and a layer made current meanwhile stays current.+ #[test]+ fn a_bitmap_read_off_the_ui_thread_is_pasted_into_its_layer() {+ let (mut app, _) = pasting_app(true, Some(Flavour { mime: PNG, data: picture(Png, 60, 40) }), None, true);+ let first = app.session.doc().unwrap().doc.layers[0].id;+ app.run("layer.new", json!({})).unwrap();+ let second = app.session.doc().unwrap().current_layer().unwrap();+ app.run("layer.setCurrent", json!({"id": first.0})).unwrap();+ crate::menus::invoke(&mut app, "edit.paste", json!({}));+ app.run("layer.setCurrent", json!({"id": second.0})).unwrap();+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let pasted = st.selection.objects[0];+ assert_eq!(st.doc.layer_containing(pasted), Some(first), "pasted into the layer current at the paste");+ assert_eq!(st.current_layer(), Some(second), "the layer made current meanwhile stays current");+ // With nothing selected, a Paste in Front given in another layer while the first waits+ // goes into that layer.+ app.run("layer.setCurrent", json!({"id": first.0})).unwrap();+ app.run("select.none", json!({})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ app.run("layer.setCurrent", json!({"id": second.0})).unwrap();+ app.run("select.none", json!({})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ assert_eq!(app.placing.pending.len(), 2);+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let tops: Vec<_> = [first, second]+ .iter()+ .map(|l| st.doc.node(*l).and_then(|n| n.children()).and_then(|c| c.last()).map(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))))+ .collect();+ assert_eq!(tops, [Some(true), Some(true)], "each layer has a picture on top");+ // A group isolated while the paste waits keeps its isolation, and the paste goes into the+ // layer, where it was given.+ let squares: Vec<Value> = (0..2)+ .map(|i| app.run("shape.rectangle", json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone())+ .collect();+ app.run("select.set", json!({"ids": squares})).unwrap();+ let group = NodeId(app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap());+ app.run("select.none", json!({})).unwrap();+ crate::menus::invoke(&mut app, "edit.paste", json!({}));+ app.run("object.isolate", json!({"id": group.0})).unwrap();+ placed(&mut app);+ let st = app.session.doc().unwrap();+ let pasted = st.selection.objects[0];+ assert_eq!(st.doc.parent_of(pasted), Some(second), "pasted into the layer, not the group isolated meanwhile");+ assert_eq!(st.isolation, Some(group), "the group stays isolated");+ // An object selected in another layer while the paste waits makes that layer current, and+ // it stays current after the paste.+ app.run("object.exitIsolation", json!({})).unwrap();+ let in_second = app.run("shape.rectangle", json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone();+ app.run("layer.setCurrent", json!({"id": first.0})).unwrap();+ let in_first = app.run("shape.rectangle", json!({"x": 300, "y": 100, "width": 20, "height": 20})).unwrap()["id"].clone();+ app.run("select.set", json!({"ids": [in_first]})).unwrap();+ crate::menus::invoke(&mut app, "edit.pasteInFront", json!({}));+ app.run("select.set", json!({"ids": [in_second]})).unwrap();+ assert_eq!(app.session.doc().unwrap().current_layer(), Some(second));+ placed(&mut app);+ let st = app.session.doc().unwrap();+ assert_eq!(st.doc.layer_containing(st.selection.objects[0]), Some(first), "pasted next to the object selected at the paste");+ assert_eq!(st.current_layer(), Some(second), "the layer of the object selected meanwhile stays current");+ }+ /// The Place dialog's OK with one file, with several (the place cursor and its thumbnails), a /// drop on the canvas and [`crate::io::place_bytes`] read their files off the UI thread; /// `file.place` and `file.place.queue` from MCP and scripts read them at once, with the replies@@ -12,7 +12,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::channel; use serde_json::{Value, json};-use vectorcraft_engine::cmd::clipboard::{AI, BITMAP, EMF, EPS, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture};+use vectorcraft_engine::cmd::clipboard::{AI, BITMAP, EMF, EPS, Flavour, ImageRead, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg, names_a_picture}; use crate::VectorcraftApp; use crate::background::{self, Writer};@@ -27,6 +27,34 @@ pub trait SystemClipboard { fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour>; /// Does the clipboard hold one of `mimes`? Cheap enough to ask a few times a second. fn has(&mut self, mimes: &[&'static str]) -> bool;+ /// [`Self::read`], except that a bitmap the host converts to PNG (Windows' device-independent+ /// bitmap, the image Linux gives) comes back as the work that converts it+ /// ([`ClipboardRead::Later`]), for a paste that runs it off the UI thread. By default every+ /// flavour is read at once.+ fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {+ self.read(mimes).map(ClipboardRead::Now)+ }+}++/// What [`SystemClipboard::read_deferred`] read.+pub enum ClipboardRead {+ /// The flavour.+ Now(Flavour),+ /// The work that makes the flavour from the bytes read (a bitmap decoded and encoded as PNG).+ Later(MakeFlavour),+}++/// Makes a flavour from bytes read off the clipboard, on any thread; `None` when it can't be made.+pub type MakeFlavour = Box<dyn FnOnce() -> Option<Flavour> + Send>;++impl ClipboardRead {+ /// The flavour, made at once when it is work.+ pub fn flavour(self) -> Option<Flavour> {+ match self {+ Self::Now(f) => Some(f),+ Self::Later(make) => make(),+ }+ } } /// Makes a second system-clipboard handle, to check off the UI thread whether Paste has something@@ -50,6 +78,17 @@ impl Publishing { pub(crate) fn is_pending(&self) -> bool { self.pending }++ /// The number of the latest Copy or Cut: it changes with each Copy or Cut, and with each copy+ /// through egui that drops the formats not written yet.+ pub(crate) fn latest(&self) -> u64 {+ self.latest.load(Ordering::SeqCst)+ }+}++/// Does a flavour of type `mime` load with `clipboard.importImage` ([`import_command`])?+pub(crate) fn imports_as_image(mime: &str) -> bool {+ ![TEXT, SVG, PDF, AI, EPS, EMF].contains(&mime) } /// The command (and its params) that loads `f` into the internal clipboard, centred on `center`.@@ -208,44 +247,91 @@ impl VectorcraftApp { /// copy, and so does what we are still making the formats of. An error means nothing should be /// pasted. pub(crate) fn adopt_system_clipboard(&mut self) -> Result<(), String> {+ self.adopt_system_clipboard_or_defer(false).map(drop)+ }++ /// [`Self::adopt_system_clipboard`]. With `defer`, a bitmap is not loaded here: the work that+ /// makes its flavour comes back (a bitmap the host converts to PNG is converted by it,+ /// [`SystemClipboard::read_deferred`]; text that names the picture is the flavour when the+ /// picture can't be made), and the internal clipboard is left as it is.+ pub(crate) fn adopt_system_clipboard_or_defer(&mut self, defer: bool) -> Result<Option<MakeFlavour>, String> { // A paste command may run before the next tick consumes an image signal: this is that // paste, so a queued `PasteImage` must not read the clipboard a second time. self.clipboard_image_request = None; let pasted = self.clipboard_in.take(); let center = self.view().map(|v| [v.center.x, v.center.y]); let (cmd, params) = match (pasted, self.services.system_clipboard.as_mut()) {+ (Some(f), _) if defer && imports_as_image(f.mime) => return Ok(Some(Box::new(move || Some(f)))), // A picture or file the host read from the paste. (Some(f), _) if f.mime != TEXT => import_command(&f, center), (_, Some(cb)) => { if self.clipboard_publishing.pending || cb.holds_ours() {- return Ok(());+ return Ok(None); }- let Some(f) = cb.read(&PASTE_ORDER) else { return Ok(()) };+ let mut read = |mimes: &[&'static str]| if defer { cb.read_deferred(mimes) } else { cb.read(mimes).map(ClipboardRead::Now) };+ let f = match read(&PASTE_ORDER) {+ Some(ClipboardRead::Now(f)) => f,+ Some(ClipboardRead::Later(make)) => return Ok(Some(make)),+ None => return Ok(None),+ }; // Text that only names a picture (its address, or its image markup) goes with a // browser's copied picture: the picture. let text = String::from_utf8_lossy(&f.data); // Blank text is what some hosts put beside a copied picture. It is not the paste.- let f = if f.mime == TEXT && (text.trim().is_empty() || names_a_picture(&text)) { cb.read(&[BITMAP]).unwrap_or(f) } else { f };+ let f = if f.mime == TEXT && (text.trim().is_empty() || names_a_picture(&text)) {+ match read(&[BITMAP]) {+ Some(ClipboardRead::Now(picture)) => picture,+ // The text, when the picture can't be made.+ Some(ClipboardRead::Later(make)) => return Ok(Some(Box::new(move || make().or(Some(f))))),+ None => f,+ }+ } else {+ f+ };+ if defer && imports_as_image(f.mime) {+ return Ok(Some(Box::new(move || Some(f))));+ } import_command(&f, center) } // Text only: SVG markup is art, other text is left alone. (pasted, None) => { let text = pasted.map(|f| String::from_utf8_lossy(&f.data).into_owned()).or_else(|| self.services.clipboard_read.as_mut().and_then(|f| f()));- let Some(text) = text.filter(|t| looks_like_svg(t)) else { return Ok(()) };+ let Some(text) = text.filter(|t| looks_like_svg(t)) else { return Ok(None) }; if self.clipboard_published.as_deref() == Some(text.as_str()) {- return Ok(());+ return Ok(None); } self.clipboard_published = Some(text.clone()); ("clipboard.importSvg", json!({ "svg": text, "center": center })) } };+ self.session.execute(cmd, ¶ms).map(|_| None).map_err(|e| {+ self.clipboard_published = None;+ format!("Couldn't paste from the clipboard: {e}")+ })+ }++ /// Before a paste whose flavour was made off the UI thread ([`crate::placing::adopt_for_paste`]):+ /// `f` replaces the internal clipboard, centered on `center`, as+ /// [`Self::adopt_system_clipboard`] loads it. An error means nothing should be pasted.+ pub(crate) fn adopt_flavour(&mut self, f: &Flavour, center: Option<[f64; 2]>) -> Result<(), String> {+ let (cmd, params) = import_command(f, center); self.session.execute(cmd, ¶ms).map(drop).map_err(|e| { self.clipboard_published = None; format!("Couldn't paste from the clipboard: {e}") }) } + /// [`Self::adopt_flavour`] for a bitmap of type `mime` decoded off the UI thread: the image `read`+ /// replaces the internal clipboard, centered on `center`.+ pub(crate) fn adopt_image_read(&mut self, mime: &str, center: Option<[f64; 2]>, read: Result<ImageRead, String>) -> Result<(), String> {+ let params = json!({ "mime": mime, "center": center });+ read.and_then(|read| self.session.import_image_read(read, ¶ms).map(drop).map_err(|e| e.to_string())).map_err(|e| {+ self.clipboard_published = None;+ format!("Couldn't paste from the clipboard: {e}")+ })+ }+ /// The text on the system clipboard once the formats a Copy or Cut is making are written, for a /// paste into a text field or type. pub(crate) fn written_clipboard_text(&mut self) -> Option<String> {@@ -594,6 +594,9 @@ the Windows clipboard and, with SVG, from the macOS pasteboard, and an `.eps` fi and pastes nothing. Cmd+V also pastes a bitmap or PDF with no text beside it (on the key's release, as egui sends no Paste event for it). Screenshots paste on every platform: Windows reads `PNG`, else `CF_DIBV5`/`CF_DIB`, macOS the pasteboard's PNG or TIFF, Linux `image/png`, and the web the picture the browser's paste event carries (#457).+In the desktop app a Paste from the menus or the keys (`ui.key`) decodes a bitmap off the UI thread: `ui.inspect` lists+`clipboard` in `placing` until it is pasted, and an import error then shows in the status bar. `engine.execute`+`edit.paste*` reads it at once. File menu details: `file.openRecent1`…`file.openRecent30` open the recent files the `recentFilesCount` preference lists (0 hides them all; `file.recentFiles` returns the listed paths), `file.reveal` shows the document's file in the@@ -3526,8 +3526,15 @@ also before its formats are published. While a text field or the Type tool has t them. Windows carries every format both ways and pastes a bitmap from PNG, else from the device-independent bitmap screenshots copy (`CF_DIBV5`, then `CF_DIB`; alpha kept, opaque when it is zero throughout, at most 32768 px a side); macOS and Linux carry one format (the text, else the PNG) and paste text and-bitmaps (macOS the pasteboard's PNG, else TIFF, as screenshots copy them). On the web Copy publishes SVG text, and-Paste takes SVG text and the pictures and files a paste carries (a screenshot pastes as an embedded image). A `.ai`+bitmaps (macOS the pasteboard's PNG, else TIFF, as screenshots copy them). A Paste from the menus or the keys decodes+a bitmap (a copied image file too) on the thread that reads placed files, with "Pasting" in the status bar and the+busy spinner, and pastes it once it is decoded, in the document it was given in: Windows' device-independent bitmap and+Linux's image are encoded as PNG there, and the bitmap is read as `clipboard.importImage` reads it. The paste is+centered where the view was and goes where new art went (the current layer, the isolated group, the object Draw+Inside drew into), and Paste in Front and Paste in Back paste next+to the objects selected when it was given (a second paste given before the first is pasted goes next to the first).+A Copy or Cut made meanwhile keeps the internal clipboard it made. `edit.paste*` over MCP and scripts reads the bitmap at once.+On the web Copy publishes SVG text, and Paste takes SVG text and the pictures and files a paste carries (a screenshot pastes as an embedded image). A `.ai` or `.ait` file that holds a PDF, copied in a file manager or carried by a paste, is loaded with `clipboard.importPdf {editingData: true}`, and its objects go into the active layer. @@ -960,6 +960,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - File › Place reads Word `.docx` documents as area type with the same formatting, taken from the document defaults, the paragraph and character styles (their toggle properties reversing each other) and direct formatting, the theme's fonts included. Table rows are paragraphs with their cells separated by tabs; hidden and deleted text, field instructions, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests_textimport.rs`). - A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests_textimport.rs`). - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before.+ - On the desktop, a Paste from the menus or the keys decodes another app's bitmap, or a copied image file, on the same thread, with "Pasting" in the status bar and the busy spinner, and pastes it once it is decoded: Windows' device-independent bitmap and Linux's image are encoded as PNG there instead of on the UI thread. A Copy or Cut made meanwhile keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before.