Clipboard: honor another program's clipboard ownership on paste (#6225)

Paste selected WordCraft's own fragment whenever the system clipboard text matched, even when the host reported that another program owned the clipboard, so a plain-text copy made elsewhere could paste the earlier bold fragment back. Read privateClipboard in paste_content and in the Formatted Text source, using the session fragment only when ownership is true or the caller omits the flag (agents and the CLI keep working). Carry the flag through the Paste Special request and dialog so its format list matches.
This commit is contained in:
batistalucasv committed 2026-11-12 12:22:28 -03:00
1 parent 02f8022e57
commit ab90b7142e
5 files changed
+85 -6

No files matched your search

+4 -2
View File
@@ -79,12 +79,14 @@ pub fn specs() -> Vec<CommandSpec> {
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;
+19 -4
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
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("</") || 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<String> {
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<String> {
_ => 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<Pasted, CmdError> {
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()))
{
+46
View File
@@ -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<String> {
let mut out = Vec::new();
+3
View File
@@ -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 }
}
+13
View File
@@ -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.