mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-03 17:25:28 +00:00
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)
This commit is contained in:
1 parent
22a40fd57e
commit
d2bdb0866f
5 files changed
+108
-38
No files matched your search
@@ -109,7 +109,7 @@ fn position_param(p: &Value, base: &AnchorPosition) -> Result<AnchorPosition> {
|
||||
}
|
||||
|
||||
/// 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")) }
|
||||
|
||||
@@ -43,24 +43,64 @@ fn copy_text(s: &mut Session) -> Result<Option<String>> {
|
||||
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::<Vec<_>>().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<String>, formatted: bool) -> Result<Value> {
|
||||
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<designcraft_doc::Item> = 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<designcraft_doc::Item> = 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<String>, 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<CommandSpec> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StoryId, StoryId>) {
|
||||
/// 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<StoryId, StoryId>) {
|
||||
it.id = ItemId(dst.alloc());
|
||||
let my_id = it.id;
|
||||
if let Some(m) = it.media.as_mut()
|
||||
|
||||
@@ -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::<String>()
|
||||
})
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.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);
|
||||
|
||||
Reference in new issue
Block a user