diff --git a/ROADMAP.md b/ROADMAP.md index fd17b3a1c..42ccd19dc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -148,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it | | 2026-11-01 | Copy, Cut, Paste and Duplicate of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | | 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 19fd48bf1..f6003dd04 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -11,6 +11,52 @@ pub(crate) fn system_clipboard_text(t: &str) -> String { if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } } +/// Whether pasted `text` (and the clipboard's `html`) only name a picture that is on the clipboard +/// too, as a browser's Copy Image leaves them: the picture's address, perhaps followed by its alt +/// text, or HTML that is a lone ``. Then Ctrl+V pastes the picture, as PowerPoint does. +/// Text with NULs is UTF-16 read as UTF-8 (Firefox's `text/x-moz-url`), never words to keep. +pub(crate) fn names_a_picture(text: &str, html: Option<&str>) -> bool { + if text.contains('\0') { + return true; + } + if let Some(h) = html + && h.to_ascii_lowercase().contains("`/`"); + } else if rest.starts_with('<') { + i = skip_to(">"); + } else { + let end = rest.find('<').map_or(html.len(), |e| i + e); + out.push_str(&html[i..end]); + i = end; + } + } + out.replace(" ", " ") +} + impl SlideApp { /// Offer the copy or cut on the system clipboard. /// diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index d6903e82d..7523308c0 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -152,8 +152,10 @@ impl SlideApp { // Text keeps its HTML (bold, italic, underline) when the host can read it. let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); // egui 0.37 sends `PasteImage` directly; the services fallback stays for - // integrations that still send an empty `Paste`. Never insert twice. - let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; + // integrations that still send an empty `Paste`, and for a browser's Copy + // Image, whose text is only the picture's link. Never insert twice. + let wants_picture = text.is_empty() || super::clipboard::names_a_picture(&text, html.as_deref()); + let img = if wants_picture && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; let revision = self.session.active().map(|d| d.revision); let r = match img { Some(png) => self diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 98f3b85c7..47192ad36 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -378,6 +378,50 @@ fn title_bar_export_opens_the_export_dialog() { assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); } +/// A browser's Copy Image puts the picture on the clipboard with text that only names it: the +/// address (and the alt text), and an `` fragment as HTML. Ctrl+V pastes the picture, as +/// PowerPoint does, instead of a text box with the link. Without a picture the link pastes as text. +#[test] +fn ctrl_v_pastes_the_picture_a_browser_copied_not_its_link() { + let url = "https://github.com/storytold/deckcraft/raw/main/assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png"; + let caption = format!("{url}\nDeckCraft icon: a presentation screen with a bar chart on an orange tile"); + for (text, html, with_picture, pictures_after) in [ + (caption.clone(), Some(format!("\"DeckCraft")), true, 1), + (url.to_string(), None, true, 1), + // Firefox's text/x-moz-url is UTF-16; read as UTF-8 it carries NULs. + (url.chars().flat_map(|c| [c, '\0']).collect(), None, true, 1), + (url.to_string(), None, false, 0), + ("See https://example.com for the photos".to_string(), None, true, 0), + ] { + let session = Session::with_new(); + let png = { + let d = session.active().unwrap(); + deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0 + }; + let services = Services { + clipboard_image: Some(Box::new(move || with_picture.then(|| png.clone()))), + clipboard_html: Some(Box::new(move || html.clone())), + ..Default::default() + }; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(session, services), + ); + harness.run_steps(2); + let shapes_before = harness.state().session.active().unwrap().doc.slides[0].shapes.len(); + harness.event(egui::Event::Paste(text.clone())); + harness.run_steps(2); + let d = harness.state().session.active().unwrap(); + let shapes = &d.doc.slides[0].shapes; + let pictures = shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count(); + assert_eq!(pictures, pictures_after, "{text:?}"); + assert_eq!(shapes.len(), shapes_before + 1, "one shape, picture or text, for {text:?}"); + } +} + /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and /// swallows the key press, so only the V release reaches the app. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 75a92355b..27c7ef7c7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -161,7 +161,7 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Estimate:** 3–5 h. **Beta blocker.** ### Image paste fails on Windows (#1414) -- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.14.1 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01. #1414 may have the same cause: re-test on Windows. +- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.14.1 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01, as is a browser's Copy Image on Linux, which pasted its link because Ctrl+V preferred any text over the picture. #1414 may have either cause: re-test on Windows. - **Impact:** copy-paste of screenshots is a core workflow. - **Estimate:** 2–4 h. **Beta blocker.**