From d2bdb0866fb73cf9aac012dc4c08a8854ae2b92e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:26:43 -0400 Subject: [PATCH] Paste: other apps' text goes into a text caret after copying objects (#1344) * Paste: other apps' text goes into a text caret even after copying objects (#212) * Paste: text that matches a copied frame still pastes; groups with text frames aren't anchored (#212) --- crates/engine/src/cmd/anchored.rs | 2 +- crates/engine/src/cmd/edit.rs | 109 ++++++++++++++++++++++++++++-- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/object.rs | 3 +- crates/ui-egui/src/lib.rs | 31 +-------- 5 files changed, 108 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/anchored.rs b/crates/engine/src/cmd/anchored.rs index dc712745..6d09f89d 100644 --- a/crates/engine/src/cmd/anchored.rs +++ b/crates/engine/src/cmd/anchored.rs @@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result { } /// Text frames can't be anchored yet (their stories need frames on a spread). -fn check_item(it: &Item) -> Result<()> { +pub(crate) fn check_item(it: &Item) -> Result<()> { let mut ok = true; it.walk(&mut |i| ok &= !matches!(i.content, Content::Text(_))); if ok { Ok(()) } else { Err(bad("anchored.insert", "text frames can't be anchored yet")) } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e5a2d229..fb8848a9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result> { Ok(Some(text)) } +/// Plain text for the system clipboard after copying objects: the copied text frames' stories, +/// or U+FFFC (object replacement character) when none has text. Paste reads it back as "the +/// copied objects", not as text. +pub fn copied_objects_text(s: &Session) -> String { + let Some(clip) = &s.clipboard else { return '\u{FFFC}'.to_string() }; + let mut stories = Vec::new(); + for it in clip.spreads.first().map(|sp| sp.items.as_slice()).unwrap_or_default() { + it.walk(&mut |i| { + if let Some(tf) = i.text_frame() + && !stories.contains(&tf.story) + { + stories.push(tf.story); + } + }); + } + let text = stories.iter().filter_map(|id| clip.story(*id)).map(plain).filter(|t| !t.is_empty()).collect::>().join("\n"); + if text.is_empty() { '\u{FFFC}'.to_string() } else { text } +} + +/// `text` (the system clipboard's) is what the last copy put there: objects were copied last and +/// it is their placeholder, so Paste stands for the copied objects. +fn is_objects_placeholder(s: &Session, text: &str) -> bool { + s.text_clipboard.is_none() && s.clipboard.is_some() && text.replace("\r\n", "\n") == copied_objects_text(s).replace("\r\n", "\n") +} + /// Paste text at the insertion point: the formatted copy when the system clipboard still holds /// what was copied here (or `formatted` is forced), else plain text in the insertion format. +/// With `formatted`, objects copied last paste as anchored objects when `text` is absent or their +/// placeholder; other text was copied in another app since. Objects that can't be anchored (text +/// frames, into a table cell) paste as their placeholder text. fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result { let t = s.doc()?.selection.text.ok_or_else(|| super::bad("edit.paste", "no insertion point"))?; let clip = s.text_clipboard.clone(); - // Items copied last (no text copied since) paste into the text as anchored objects. - if formatted - && clip.is_none() + let own = formatted && clip.is_none() && s.clipboard.is_some() && text.as_deref().is_none_or(|x| is_objects_placeholder(s, x)); + if own && t.cell.is_none() && let Some(items) = s.clipboard.clone() { let list: Vec = items.spreads.first().map(|sp| sp.items.iter().map(|i| (**i).clone()).collect()).unwrap_or_default(); - if !list.is_empty() && list.iter().all(|i| !i.is_text_frame()) { + if !list.is_empty() && list.iter().all(|i| super::anchored::check_item(i).is_ok()) { return s.edit(|d, sel| { let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t.range(); st.delete(r.start.min(st.len())..r.end.min(st.len())); - let end = super::anchored::anchor_items(d, t.story, r.start, list.clone(), &Default::default())?; + // Fresh ids, story and asset copies for every paste, as on the page. + let mut stories = std::collections::HashMap::new(); + let fresh: Vec = list + .iter() + .map(|i| { + let mut i = i.clone(); + super::object::renumber(d, &items, &mut i, &mut stories); + if d.layer(i.layer).is_none() { + i.layer = d.default_layer(); + } + i + }) + .collect(); + let end = super::anchored::anchor_items(d, t.story, r.start, fresh, &Default::default())?; sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); Ok(json!({"pos": end, "anchored": list.len()})) }); @@ -80,6 +120,8 @@ fn paste_text(s: &mut Session, text: Option, formatted: bool) -> Result< Ok(json!({"pos": end, "formatted": true})) }); } + // U+FFFC stands for objects with no text. + let text = if own { Some(copied_objects_text(s)).filter(|m| m != "\u{FFFC}") } else { text }; let text = text.or_else(|| clip.map(|(_, p)| p)).ok_or_else(|| super::bad("edit.paste", "clipboard is empty"))?; let text = outside_text(&text)?; s.execute("text.insert", &json!({"text": text, "raw": true})) @@ -327,7 +369,8 @@ pub fn specs() -> Vec { if s.doc()?.selection.text.is_some() { return paste_text(s, text.map(str::to_string), true); } - if let Some(text) = text { + // The placeholder of an object copy pastes the copied objects. + if let Some(text) = text.filter(|t| !is_objects_placeholder(s, t)) { // What our own text copy put on the clipboard keeps its formatting. let slice = s.text_clipboard.clone().filter(|(_, plain)| text.replace("\r\n", "\n") == *plain).map(|(slice, _)| slice); return paste_text_frame(s, p, text, slice.as_deref()); @@ -538,6 +581,60 @@ mod text_clipboard_tests { s.doc().unwrap().doc.check().unwrap(); } + /// Copying a rectangle, then text in another app, pasted the rectangle into the text (#212). + #[test] + fn paste_at_a_caret_prefers_other_apps_text_to_copied_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let story = frame(&mut s, "ab"); + let rect = s.execute("frame.create", &json!({"rect": [72, 400, 200, 480]})).unwrap()["id"].clone(); + s.execute("selection.set", &json!({"ids": [rect]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let marker = copied_objects_text(&s); + s.execute("text.select", &json!({"story": story.0, "anchor": 2, "focus": 2})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": "From elsewhere"})).unwrap(); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "abFrom elsewhere"); + // What the object copy put on the system clipboard still pastes the objects. + assert_eq!(s.execute("edit.paste", &json!({"text": marker})).unwrap()["anchored"], 1); + // With no insertion point too: the rectangle, not a text frame holding the marker. + s.execute("edit.deselectAll", &json!({})).unwrap(); + let r = s.execute("edit.paste", &json!({"text": marker})).unwrap(); + let id = ItemId(r["ids"][0].as_u64().unwrap()); + assert!(s.doc().unwrap().doc.item(id).unwrap().text_frame().is_none()); + s.doc().unwrap().doc.check().unwrap(); + // Paste without Formatting inserts the text given; empty text is no text. + s.execute("text.select", &json!({"story": story.0, "anchor": 0, "focus": 0})).unwrap(); + assert!(s.execute("edit.pasteWithoutFormatting", &json!({"text": ""})).is_err()); + } + + /// Text frames, and groups holding one, can't be anchored: at a caret their copy pastes as text. + #[test] + fn copied_text_frames_paste_at_a_caret_as_their_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let target = frame(&mut s, "ab"); + let copy_and_paste = |s: &mut Session, ids: Value, text: Value| { + s.execute("selection.set", &json!({"ids": ids})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": target.0, "anchor": 0, "focus": 0})).unwrap(); + s.execute("edit.paste", &json!({"text": text})).unwrap() + }; + let chapter = s.execute("frame.create", &json!({"rect": [72, 400, 300, 480], "content": "text", "text": "Chapter 1"})).unwrap()["id"].clone(); + // The same text copied in another app, and the system clipboard read back as absent. + let r = copy_and_paste(&mut s, json!([chapter]), json!("Chapter 1")); + assert!(r.get("anchored").is_none(), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1ab"); + // A group with a text frame inside, its own placeholder on the system clipboard. + let rect = s.execute("frame.create", &json!({"rect": [72, 500, 200, 560]})).unwrap()["id"].clone(); + let group = s.execute("object.group", &json!({"ids": [chapter, rect]})).unwrap()["id"].clone(); + let r = copy_and_paste(&mut s, json!([group]), Value::Null); + assert!(r.get("anchored").is_none(), "{r}"); + assert_eq!(s.doc().unwrap().doc.story(target).unwrap().text, "Chapter 1Chapter 1Chapter 1ab"); + s.doc().unwrap().doc.check().unwrap(); + } + #[test] fn outside_text_never_pastes_markers() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a4d5413b..1d6294ea 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod endnotes; mod export; mod file; pub(crate) mod find; +pub use edit::copied_objects_text; pub use find::same_find_domain; pub(crate) mod flex; mod fonts; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 82f33a6d..4ccb5c82 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1074,7 +1074,8 @@ fn adopt_or_dangle(dst: &mut Document, src: &Document, id: AssetId) -> AssetId { dst.adopt_asset(src, id).unwrap_or_else(|| AssetId(dst.alloc())) } -fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) { +/// Give `it` (an item of `src`) fresh ids from `dst`, with copies of its stories and the assets it uses. +pub(crate) fn renumber(dst: &mut Document, src: &Document, it: &mut Item, stories: &mut HashMap) { it.id = ItemId(dst.alloc()); let my_id = it.id; if let Some(m) = it.media.as_mut() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3674b3e8..0c155851 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -810,7 +810,7 @@ impl DesignApp { Some(t.to_string()) } None if !self.native_menu => { - self.object_clip_text = Some(self.copied_objects_text()); + self.object_clip_text = Some(designcraft_engine::cmd::copied_objects_text(&self.session)); self.object_clip_text.clone() } None => { @@ -910,35 +910,6 @@ impl DesignApp { params } - /// Plain text for the system clipboard after copying objects: the copied text frames' stories, - /// or U+FFFC (object replacement character) when none has text. - fn copied_objects_text(&self) -> String { - let mut stories = Vec::new(); - if let Some(clip) = &self.session.clipboard - && let Some(sp) = clip.spreads.first() - { - for it in &sp.items { - it.walk(&mut |i| { - if let Some(tf) = i.text_frame() - && !stories.contains(&tf.story) - { - stories.push(tf.story); - } - }); - } - } - let text = stories - .iter() - .filter_map(|id| self.session.clipboard.as_ref()?.story(*id)) - .map(|st| { - st.text.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\u{2028}' { '\n' } else { c }).collect::() - }) - .filter(|t| !t.is_empty()) - .collect::>() - .join("\n"); - if text.is_empty() { '\u{FFFC}'.to_string() } else { text } - } - /// Handle requests produced by tools/commands (dialogs, view changes, file pickers). pub fn after_engine(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests);