mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 11:59:36 -06:00
Ctrl+V pastes a picture copied in a browser, not its link
A browser's Copy Image puts the picture on the clipboard together with text that only names it (the address and alt text, or Firefox's UTF-16 text/x-moz-url read as UTF-8) and an <img> HTML fragment. Ctrl+V only looked for a picture when the text was empty, so it pasted a text box with the link. When the text or HTML only names a picture, paste the picture; with no picture on the clipboard the link still pastes as text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e1cfe8516e
commit
f006b5d8ce
@@ -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 |
|
||||
|
||||
@@ -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 `<img>`. 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("<img")
|
||||
&& visible_text(h).trim().is_empty()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let mut lines = text.trim().lines();
|
||||
let first = lines.next().unwrap_or("").trim();
|
||||
let link = ["http://", "https://", "file://", "data:image/"].iter().any(|p| first.to_ascii_lowercase().starts_with(p));
|
||||
link && !first.contains(char::is_whitespace) && lines.count() <= 1
|
||||
}
|
||||
|
||||
/// The text of an HTML fragment outside its tags (and outside comments and `<style>`/`<script>`
|
||||
/// bodies, which browsers sometimes put around a copied picture).
|
||||
fn visible_text(html: &str) -> String {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < html.len() {
|
||||
let rest = &lower[i..];
|
||||
let skip_to = |end: &str| rest.find(end).map(|e| i + e + end.len()).unwrap_or(html.len());
|
||||
if rest.starts_with("<!--") {
|
||||
i = skip_to("-->");
|
||||
} else if rest.starts_with("<style") {
|
||||
i = skip_to("</style>");
|
||||
} else if rest.starts_with("<script") {
|
||||
i = skip_to("</script>");
|
||||
} 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.
|
||||
///
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<img>` 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!("<img src=\"{url}\" alt=\"DeckCraft icon\">")), 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]
|
||||
|
||||
+1
-1
@@ -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.**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user