diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f878cc9691..bcc9cff404 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -79,12 +79,14 @@ pub fn specs() -> Vec { Some("the Spike is empty") } }), - CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste).key("Mod+V / Shift+Insert").params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#), + CommandSpec::new("edit.paste", "Paste", "Home › Clipboard", paste) + .key("Mod+V / Shift+Insert") + .params(r#"{"text"?: string, "html"?: string, "rtf"?: string, "privateClipboard"?: bool}"#), CommandSpec::new("edit.pasteText", "Paste: Keep Text Only", "Home › Clipboard › Paste", paste_text) .params(r#"{"text"?: string}"#) .key("Mod+Shift+Alt+V"), CommandSpec::new("edit.pasteMerge", "Paste: Merge Formatting", "Home › Clipboard › Paste", paste_merge) - .params(r#"{"text"?: string, "html"?: string, "rtf"?: string}"#), + .params(r#"{"text"?: string, "html"?: string, "rtf"?: string, "privateClipboard"?: bool}"#), CommandSpec::new("edit.clipboardPane", "Clipboard Pane", "Home › Clipboard", |s, v| { let on = p::bool(v, "value").unwrap_or(!s.view.clipboard_pane); s.view.clipboard_pane = on; diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index 4a8aad3b02..e4df30bd95 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("edit.pasteSpecial", "Paste Special", "Home › Clipboard › Paste", paste_special) .key("Mod+Alt+V") - .params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64}"#), + .params(r#"{"as"?: "formatted"|"rtf"|"html"|"picture"|"text", "text"?: string, "html"?: string, "rtf"?: string, "picture"?: base64, "privateClipboard"?: bool}"#), ] } @@ -58,11 +58,18 @@ fn looks_like_html(t: &str) -> bool { t.starts_with('<') && (t.contains("")) } +/// Whether the system clipboard still holds this window's copy. A front end that can check says +/// so in `privateClipboard`; callers that cannot (an agent, the CLI) omit it, and the session's +/// copy is used as before. +fn owns_clipboard(v: &Value) -> bool { + p::bool(v, "privateClipboard").unwrap_or(true) +} + /// The source for a format, if the clipboard has it. fn source(s: &Session, v: &Value, fmt: &str) -> Option { let non_empty = |t: String| if t.is_empty() { None } else { Some(t) }; match fmt { - "formatted" => { + "formatted" if owns_clipboard(v) => { let f = s.clipboard.as_ref()?; // System clipboard text that isn't our own copy means someone else copied since. match p::str(v, "text") { @@ -70,6 +77,7 @@ fn source(s: &Session, v: &Value, fmt: &str) -> Option { _ => Some(String::new()), } } + "formatted" => None, "rtf" => p::str(v, "rtf").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_rtf(t))).and_then(non_empty), "html" => p::str(v, "html").map(str::to_string).or_else(|| clip_text(s, v).filter(|t| looks_like_html(t))).and_then(non_empty), "picture" => p::str(v, "picture").map(str::to_string).and_then(non_empty), @@ -105,6 +113,10 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult { req[k] = json!(t); } } + // Keep the ownership result so the dialog's list is the same as this one. + if let Some(own) = p::bool(v, "privateClipboard") { + req["privateClipboard"] = json!(own); + } // Only the newest dialog request matters; older ones would pin their payloads. s.ui_requests.retain(|r| r["open"] != "pasteSpecial"); s.push_ui_request(req); @@ -265,10 +277,13 @@ pub enum Pasted { } /// What Paste inserts: WordCraft's own copy while the system clipboard still holds it, else -/// another program's formatted copy (HTML, RTF), else the system clipboard's text. +/// another program's formatted copy (HTML, RTF), else the system clipboard's text. A front end +/// that knows another program owns the clipboard passes `privateClipboard: false`, which keeps +/// the fragment out even when the text matches. pub fn paste_content(s: &mut Session, v: &Value) -> Result { let text = p::str(v, "text").map(|t| t.replace("\r\n", "\n")); - if let Some(f) = &s.clipboard + if owns_clipboard(v) + && let Some(f) = &s.clipboard && text.as_ref().is_none_or(|t| f.clipboard_text() == *t) && (text.is_some() || (p::str(v, "html").is_none() && p::str(v, "rtf").is_none())) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6439b3de9e..5fe96f0729 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1193,6 +1193,52 @@ fn clipboard_round_trip() { assert_eq!(text(&s), "plain\ntext"); } +/// Whether any run in the body is bold. +fn any_bold(s: &Session) -> bool { + s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| p.runs.iter()).any(|r| r.props.bold == Some(true)) +} + +#[test] +fn paste_from_another_program_does_not_bring_back_our_fragment() { + // Review on #6225: when another program owns the clipboard, matching text must not make + // Paste put WordCraft's own formatted fragment back. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bold"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "edit.copy", json!({})); + assert_eq!(s.clipboard_text, "Bold"); + + // The same text, copied by another program: paste it as plain text, not our bold fragment. + run(&mut s, "document.setText", json!({"text": "Plain "})); + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({"text": "Bold", "privateClipboard": false})); + assert_eq!(text(&s), "Plain Bold"); + assert!(!any_bold(&s), "{:?}", s.doc.body); + + // Callers that omit the flag (an agent, the CLI) still get the session's own copy. + run(&mut s, "caret.docEnd", json!({})); + run(&mut s, "edit.paste", json!({"text": "Bold"})); + assert!(any_bold(&s), "{:?}", s.doc.body); +} + +#[test] +fn paste_special_does_not_offer_our_fragment_for_another_programs_copy() { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Bold"})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "edit.copy", json!({})); + + let r = run(&mut s, "edit.pasteSpecial", json!({"text": "Bold", "privateClipboard": false})); + let ids: Vec<&str> = r["formats"].as_array().map(|a| a.iter().filter_map(|f| f["id"].as_str()).collect()).unwrap_or_default(); + assert!(!ids.contains(&"formatted"), "{ids:?}"); + assert_eq!(s.ui_requests.last().and_then(|r| r.get("privateClipboard")), Some(&json!(false)), "{:?}", s.ui_requests); + + let e = s.run("edit.pasteSpecial", &json!({"as": "formatted", "text": "Bold", "privateClipboard": false})); + assert!(e.is_err(), "another program's copy cannot be pasted as our fragment"); +} + /// Equations in the paragraphs of a session's body, as linear text. fn equations(s: &Session) -> Vec { let mut out = Vec::new(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8f20dc4ecb..c9872347a9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -869,6 +869,9 @@ impl Dialog { kept[k] = json!(t); } } + if let Some(own) = payload.get("privateClipboard").and_then(Value::as_bool) { + kept["privateClipboard"] = json!(own); + } let note = if formats.is_empty() { crate::system_clipboard::nothing_to_paste(app) } else { String::new() }; Dialog::PasteSpecial { formats, choice: 0, payload: kept, note } } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index eb31a99c32..7154033a25 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3,6 +3,19 @@ use wordcraft_doc::Pos; use wordcraft_doc::props::Align; use wordcraft_engine::Session; +/// Paste Special lists and pastes the same formats the engine chose: the clipboard ownership +/// result must survive into the dialog's payload, or another program's copy could be replaced +/// by WordCraft's own fragment. +#[test] +fn paste_special_dialog_keeps_the_clipboard_ownership_result() { + let a = WordApp::new(Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + let d = Dialog::paste_special(&a, &json!({"text": "Bold", "privateClipboard": false})); + let Dialog::PasteSpecial { formats, payload, .. } = d else { panic!("not Paste Special") }; + assert_eq!(formats, ["text"], "another program's copy isn't our fragment"); + assert_eq!(payload.get("privateClipboard"), Some(&json!(false))); + assert_eq!(payload.get("text").and_then(Value::as_str), Some("Bold")); +} + /// #3931: hovering Insert › Table's grid asks the page for a preview of that table (rows by /// columns) and changes nothing; leaving the grid drops it; a click inserts the table with no /// preview left over for the page to show on top of it.