Browse / github.com/storytold/deckcraft / patch
Paste: slides copied in one DeckCraft window paste in another
Paste: slides copied in one DeckCraft window paste in another ## What this fixes Copy a slide, open another deck from the file manager (which starts a **separate DeckCraft process**), press Ctrl+V: nothing is pasted (or only the slide titles, as text). Copied slides lived only in the copying process's `Session::clipboard`; `publish_clipboard` put only their titles on the system clipboard. Within one process (File › New) pasting already worked; I confirmed that with a harness test before changing anything. ## Change - **Copy** (`crates/engine/src/cmd/edit.rs`): `edit.copy`/`edit.cut` of slides also return HTML: the visible titles, preceded by `<!--dec
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Naseq (imported from pull request #1372, open)
- Base
ee875abffa1a31cb760c843bb29e79391a90a766- Size
- +216 −10 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
17b5aaadae41989fbcf537cb
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/edit.rs crates/engine/src/lib.rs crates/engine/src/tests.rs crates/format/src/lib.rs crates/ui-egui/src/app/clipboard.rs
@@ -4,7 +4,7 @@ use std::collections::HashMap; use std::sync::Arc; use deckcraft_model::text::TextBody;-use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};+use deckcraft_model::{MediaId, MediaItem, Shape, ShapeId, Slide, SlideId}; use serde_json::{Value, json}; use super::{@@ -98,7 +98,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> { mids.extend(crate::media_refs::slide_media(sl)); } let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();- let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");+ let plain = slide_titles(&slides); s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() }; return Ok(plain); }@@ -140,8 +140,14 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({"text": t, "html": html})) } -/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes).-fn copied_html(s: &Session) -> String {+/// HTML for what was just copied, when it is text (a selection, or the text on the copied shapes)+/// or slides.+fn copied_html(s: &mut Session) -> String {+ if !s.clipboard.slides.is_empty() {+ let (html, id) = slides_html(s);+ s.clipboard.slides_payload = id;+ return html;+ } if let Some(body) = &s.clipboard.text { return richclip::body_to_html(body); }@@ -263,10 +269,88 @@ fn bring_media(cmd: &str, doc: &mut deckcraft_model::Presentation, media: &[deck Ok(map) } +/// Copied slides reach another DeckCraft process (a deck opened from the file manager) in the+/// clipboard HTML: a comment holding them as a `.deckcraft` file in base64, before their titles.+const SLIDES_MARK: &str = "<!--deckcraft-slides:";+/// Larger copies paste only in the window they were copied in.+const MAX_SLIDES_B64: usize = 16 << 20;++/// The plain text copied slides leave: their titles, a line each.+fn slide_titles(slides: &[Slide]) -> String {+ slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n")+}++/// The clipboard HTML for the copied slides and the identity of the payload it carries:+/// `<!--deckcraft-slides:{id}:{base64}-->` before the visible titles. `None` when the copy is too+/// large to carry (it then pastes only in this window).+fn slides_html(s: &Session) -> (String, Option<u64>) {+ let c = &s.clipboard;+ let visible = richclip::body_to_html(&TextBody::from_text(&c.plain));+ // Media saved as is can't shrink: past the limit, don't serialize it only to drop it.+ let stored: usize = c.media.iter().filter(|m| deckcraft_format::is_compressed(&m.content_type)).map(|m| m.data.len()).sum();+ if stored.div_ceil(3).saturating_mul(4) > MAX_SLIDES_B64 {+ return (visible, None);+ }+ let Ok(st) = s.doc() else { return (visible, None) };+ let mut deck = (*st.doc).clone();+ deck.slides = c.slides.iter().cloned().map(Arc::new).collect();+ deck.media = c.media.clone();+ match deckcraft_format::save(&deck) {+ Ok(bytes) if bytes.len().div_ceil(3) * 4 <= MAX_SLIDES_B64 => {+ let id = {+ use std::hash::{Hash, Hasher};+ let mut h = std::hash::DefaultHasher::new();+ bytes.hash(&mut h);+ h.finish()+ };+ (format!("{SLIDES_MARK}{id:016x}:{}-->{visible}", super::base64_encode(&bytes)), Some(id))+ }+ _ => (visible, None),+ }+}++/// The payload identity and base64 in clipboard HTML from [`slides_html`].+fn slides_payload(html: &str) -> Option<(u64, &str)> {+ let start = html.find(SLIDES_MARK)? + SLIDES_MARK.len();+ let rest = html.get(start..)?;+ let (id, rest) = rest.split_once(':')?;+ let id = u64::from_str_radix(id, 16).ok()?;+ Some((id, rest.get(..rest.find("-->")?)?))+}++/// The slides and their media in a payload. Clipboard bytes are untrusted: the size is capped and+/// the file goes through the checked loader.+fn load_slides(b64: &str) -> Option<(Vec<Slide>, Vec<MediaItem>)> {+ if b64.len() > MAX_SLIDES_B64 {+ return None;+ }+ // A payload missing a part (media without its bytes) is refused rather than pasted half.+ let (deck, damaged) = deckcraft_format::load_checked(&super::base64_decode(b64)?).ok()?;+ if !damaged.is_empty() {+ return None;+ }+ Some((deck.slides.iter().map(|x| (**x).clone()).collect(), deck.media))+}+ fn paste(s: &mut Session, p: &Value) -> Result<Value> {+ // Windows hands clipboard text over with CRLF line ends; ours are LF.+ let text = str_param(p, "text").map(|t| t.replace("\r\n", "\n"));+ let text = text.as_deref();+ // Slides copied in another DeckCraft process arrive in the clipboard HTML; slides this session+ // copied carry its own payload identity and paste from its clipboard as before. A marker left+ // behind by an older copy is not taken: the clipboard text no longer says those slides' titles.+ if let Some((id, b64)) = str_param(p, "html").and_then(slides_payload)+ && s.clipboard.slides_payload != Some(id)+ && let Some((slides, media)) = load_slides(b64)+ {+ let plain = slide_titles(&slides);+ if text.is_none_or(|t| t == plain) {+ s.clipboard = crate::Clipboard { slides, media, plain, slides_payload: Some(id), ..Default::default() };+ }+ } // External plain text (from the system clipboard) wins when given and differs from ours. // HTML from Pages or Keynote keeps bold, italic and underline when it says the same words.- if let Some(t) = str_param(p, "text")+ if let Some(t) = text && t != s.clipboard.plain { if let Some(html) = str_param(p, "html")@@ -467,6 +467,9 @@ pub struct Clipboard { pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>, /// How many times the shapes were pasted (each paste offsets further). pub pastes: u32,+ /// Identity of the copied slides' payload on the system clipboard, so a paste can tell this+ /// copy from another process's.+ pub slides_payload: Option<u64>, } pub struct Session {@@ -2185,6 +2185,123 @@ mod copy_remap { assert_eq!(table_pictures(&b, &shape(&b, 0, tb)), deck_pictures("b")); } + /// Slides copied in one DeckCraft process paste in another (a deck opened from the file+ /// manager starts its own process): only the system clipboard's text and HTML get there.+ #[test]+ fn slides_copied_in_another_process_paste_from_the_clipboard_html() {+ let mut a = Session::with_new();+ picture_table(&mut a, "a");+ let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+ // An untitled slide: the UI hands over empty text with the HTML.+ let (text, html) = (copied["text"].as_str().unwrap(), copied["html"].as_str().unwrap());+ let mut b = Session::with_new();+ b.execute("edit.paste", &json!({"text": text, "html": html})).unwrap();+ let d = b.doc().unwrap();+ assert_eq!(d.doc.slides.len(), 2, "the slide is pasted, not its title as text");+ let pasted = &d.doc.slides[1];+ let table = pasted.shapes.iter().find(|x| matches!(x.kind, ShapeKind::Table(_))).cloned().unwrap();+ assert_eq!(table_pictures(&b, &table), deck_pictures("a"));+ // Damaged or foreign HTML pastes as text, as before.+ let mut c = Session::with_new();+ c.execute("edit.paste", &json!({"text": "Hello", "html": "<!--deckcraft-slides:!!!-->Hello"})).unwrap();+ assert_eq!(c.doc().unwrap().doc.slides.len(), 1);+ }++ /// Another process's slides with the same (empty) title text still paste: the payload's+ /// identity, not the plain text, says whose copy it is.+ #[test]+ fn slides_from_another_process_win_over_a_local_copy_with_the_same_text() {+ let mut a = Session::with_new();+ picture_table(&mut a, "a");+ let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+ let mut b = Session::with_new();+ b.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+ assert_eq!(b.clipboard.plain, copied["text"].as_str().unwrap(), "both untitled");+ b.execute("edit.paste", &json!({"text": copied["text"], "html": copied["html"]})).unwrap();+ let d = b.doc().unwrap();+ assert!(d.doc.slides[1].shapes.iter().any(|x| matches!(x.kind, ShapeKind::Table(_))), "a's slide, not b's own");+ }++ /// The session's own copy pastes from its clipboard; a slide marker left on the clipboard by+ /// an older copy, under other text, is not pasted.+ #[test]+ fn own_and_stale_slide_payloads() {+ let mut a = Session::with_new();+ a.execute("slide.new", &json!({"title": "Hello"})).unwrap();+ let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+ assert!(a.clipboard.slides_payload.is_some());+ a.execute("edit.paste", &json!({"text": copied["text"], "html": copied["html"]})).unwrap();+ assert_eq!(a.doc().unwrap().doc.slides.len(), 3);+ let mut b = Session::with_new();+ b.execute("edit.paste", &json!({"text": "Other words", "html": copied["html"]})).unwrap();+ assert_eq!(b.doc().unwrap().doc.slides.len(), 1, "the newer text is pasted, not the old slides");+ // Two titles with a CRLF line end (Windows) still paste as slides, here and in another deck.+ a.execute("slide.new", &json!({"title": "World"})).unwrap();+ let ids: Vec<_> = a.doc().unwrap().doc.slides.iter().rev().take(2).map(|x| x.id).collect();+ a.doc_mut().unwrap().selection.slides = ids;+ let two = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+ let crlf = two["text"].as_str().unwrap().replace('\n', "\r\n");+ assert!(crlf.contains("\r\n"));+ let mut c = Session::with_new();+ c.execute("edit.paste", &json!({"text": crlf, "html": two["html"]})).unwrap();+ assert_eq!(c.doc().unwrap().doc.slides.len(), 3, "both slides, not their titles as text");+ let before = a.doc().unwrap().doc.slides.len();+ a.execute("edit.paste", &json!({"text": crlf, "html": two["html"]})).unwrap();+ assert_eq!(a.doc().unwrap().doc.slides.len(), before + 2);+ }++ /// A payload that loads with a part missing (a picture's bytes) is refused, not pasted with+ /// an empty picture: the paste falls back to the text.+ #[test]+ fn a_damaged_slide_payload_pastes_as_text() {+ use std::io::{Read, Write};+ let mut a = Session::with_new();+ picture_table(&mut a, "a");+ a.execute("slide.new", &json!({"title": "Hello"})).unwrap();+ let ids = vec![a.doc().unwrap().doc.slides[0].id];+ a.doc_mut().unwrap().selection.slides = ids;+ let html = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap()["html"].as_str().unwrap().to_string();+ let start = html.find(':').unwrap() + 1;+ let (id, rest) = html[start..].split_once(':').unwrap();+ let b64 = &rest[..rest.find("-->").unwrap()];+ let bytes = crate::cmd::base64_decode(b64).unwrap();+ // The same file without its media members.+ let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();+ let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));+ for i in 0..zip.len() {+ let mut f = zip.by_index(i).unwrap();+ if f.name().starts_with("media/") {+ continue;+ }+ let name = f.name().to_string();+ let mut data = vec![];+ f.read_to_end(&mut data).unwrap();+ out.start_file(&name, zip::write::SimpleFileOptions::default()).unwrap();+ out.write_all(&data).unwrap();+ }+ let damaged = crate::cmd::base64_encode(&out.finish().unwrap().into_inner());+ let html = format!("<!--deckcraft-slides:{id}:{damaged}-->");+ let mut b = Session::with_new();+ b.execute("edit.paste", &json!({"text": "", "html": html})).unwrap();+ assert_eq!(b.doc().unwrap().doc.slides.len(), 1, "no slide with missing pictures");+ }++ /// Media saved as is that can't fit the payload isn't serialized only to be dropped.+ #[test]+ fn slides_with_too_much_stored_media_stay_in_this_window() {+ let mut a = Session::with_new();+ {+ let st = a.doc_mut().unwrap();+ let doc = Arc::make_mut(&mut st.doc);+ let id = doc.add_media("big.jpg", "image/jpeg", vec![0u8; 13 << 20]).expect("media");+ let slide = Arc::make_mut(&mut doc.slides[0]);+ slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::Picture(PictureFill { media: id, ..Default::default() }) });+ }+ let copied = a.execute("edit.copy", &json!({"scope": "slides"})).unwrap();+ assert!(!copied["html"].as_str().unwrap().contains("deckcraft-slides"));+ assert!(a.clipboard.slides_payload.is_none());+ }+ #[test] fn copied_connectors_follow_copied_ends_and_detach_from_the_rest() { let mut s = Session::with_new();@@ -93,7 +93,9 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> { Ok(buf.into_inner()) } -fn is_compressed(ct: &str) -> bool {+/// Whether [`save`] stores media of content type `ct` as is (already compressed) rather than+/// deflating it.+pub fn is_compressed(ct: &str) -> bool { ct.starts_with("image/jpeg") || ct.starts_with("image/png") || ct.starts_with("video/")@@ -14,8 +14,8 @@ pub(crate) fn system_clipboard_text(t: &str) -> String { impl SlideApp { /// Offer the copy or cut on the system clipboard. ///- /// Slides go out as plain text. Text goes out as HTML plus the same plain words, so a paste- /// back into DeckCraft still uses the internal clipboard. A textless shape leaves the+ /// Text and slides go out as HTML plus the same plain words, so a paste back into DeckCraft+ /// still uses the internal clipboard, and slides also paste into another DeckCraft process. A textless shape leaves the /// invisible [`OBJECTS_MARKER`] rather than a picture: with only an image on the clipboard /// egui-winit drops Ctrl+V, and the shortcut could no longer paste the shape here (#182). pub(super) fn publish_clipboard(&mut self, ctx: &egui::Context, cut: bool) {@@ -23,8 +23,8 @@ impl SlideApp { let Ok(v) = self.session.execute(cmd, &json!({})) else { return }; let text = v.get("text").and_then(Value::as_str).unwrap_or("").to_string(); let html = v.get("html").and_then(Value::as_str).unwrap_or("").to_string();- if self.session.clipboard.slides.is_empty() && !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &text))- {+ // Untitled slides still need plain text, or egui-winit drops the Ctrl+V (#182).+ if !html.is_empty() && self.services.clipboard_set_html.as_mut().is_some_and(|f| f(&html, &system_clipboard_text(&text))) { return; } ctx.copy_text(system_clipboard_text(&text));