diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 3a541babc..5307d6175 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -112,7 +112,7 @@ impl Tool for TypeTool { self.clicks = (None, 0); // Pasting what we copied keeps its formatting (unless pasted text is kept plain). let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - if !committing && s.len() > 1 && s == clip && !cx.paste_plain_text { + if !committing && !s.is_empty() && s == clip && !cx.paste_plain_text { let runs = self.clipboard.clone(); return self.replace(cx, "", Some(runs)); } @@ -145,7 +145,7 @@ impl Tool for TypeTool { return false; } let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); - !(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text) + !(self.preedit.is_none() && !normalized.is_empty() && normalized == clip && !cx.paste_plain_text) } /// Whether the typing session still describes the edited document; a session ended by an outside /// command reports false until a replacement commit starts a new one. @@ -508,7 +508,7 @@ impl Tool for TypeTool { // Transient selection transferred between Type and Eyedropper; never a saved setting. "textSelection" => { if let (Some(id), Some(a), Some(b)) = ( - v.get("editing").and_then(Value::as_u64), + v.get("editing").or_else(|| v.get("id")).and_then(Value::as_u64), v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64), v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64), ) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b)) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 1c4c9399f..f788858f4 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -418,6 +418,7 @@ static CTX: OnceLock = OnceLock::new(); /// Plain text of the last text copied inside the Type tool (paste from the native menu, which /// can't read the system clipboard). static CLIP: Mutex = Mutex::new(String::new()); +static CLIP_RUNS: Mutex> = Mutex::new(None); fn text_copy(app: &mut VectorcraftApp) -> bool { let Some((id, a, b)) = text_editing(app) else { return false }; @@ -425,12 +426,22 @@ fn text_copy(app: &mut VectorcraftApp) -> bool { return false; } let Ok(r) = app.session.execute("text.getRange", &json!({"id": id.0, "start": a, "end": b})) else { return false }; - app.session.set_tool_option("copy", &r["runs"]); + let runs = r["runs"].clone(); + app.session.set_tool_option("copy", &runs); + *CLIP_RUNS.lock().unwrap_or_else(|e| e.into_inner()) = Some(runs.clone()); let text = r["text"].as_str().unwrap_or("").to_string(); if let Some(ctx) = CTX.get() { ctx.copy_text(text.clone()); } - *CLIP.lock().unwrap_or_else(|e| e.into_inner()) = text; + *CLIP.lock().unwrap_or_else(|e| e.into_inner()) = text.clone(); + if let Ok(parsed_runs) = serde_json::from_value::>(runs) + && !parsed_runs.is_empty() + { + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "", vectorcraft_doc::CharStyle::default()); + t.runs = parsed_runs; + let node = vectorcraft_doc::Node::new(vectorcraft_doc::NodeId(1), vectorcraft_doc::NodeKind::Text(Box::new(t))); + app.session.clipboard.nodes = vec![node]; + } true } @@ -449,9 +460,19 @@ fn text_key(app: &mut VectorcraftApp, k: ToolKey, m: Mods) { } } -fn text_paste(app: &mut VectorcraftApp, s: Option) { - let s = s.unwrap_or_else(|| CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone()); +fn text_paste(app: &mut VectorcraftApp, s: Option, plain: bool) { + let text_from_clip = CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let s = s.unwrap_or(text_from_clip); if !s.is_empty() { + if plain { + app.session.set_tool_option("copy", &json!([])); + } else if let Some(runs) = CLIP_RUNS.lock().unwrap_or_else(|e| e.into_inner()).clone() { + let clip_text: String = + serde_json::from_value::>(runs.clone()).unwrap_or_default().into_iter().map(|r| r.text).collect(); + if clip_text == s { + app.session.set_tool_option("copy", &runs); + } + } let v = app.view_info(); let target_before = crate::ime::target(&mut app.session, v); let before = crate::ime::stamp(&mut app.session, v); @@ -482,7 +503,8 @@ pub(crate) fn intercept_text_command(app: &mut VectorcraftApp, id: &str) -> Opti text_key(app, ToolKey::Delete, Mods::default()); } } - "edit.paste" | "edit.pasteWithoutFormatting" => text_paste(app, None), + "edit.paste" => text_paste(app, None, false), + "edit.pasteWithoutFormatting" => text_paste(app, None, true), "edit.clear" => text_key(app, ToolKey::Delete, Mods::default()), _ => return None, } @@ -570,7 +592,7 @@ pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) { text_key(app, ToolKey::Delete, Mods::default()); } } - TextInput::Paste(s) => text_paste(app, s), + TextInput::Paste(s) => text_paste(app, s, false), TextInput::Face(f) => { if let Err(e) = toggle_face(app, f) { app.status(e); diff --git a/crates/ui-egui/src/tests/clipboard.rs b/crates/ui-egui/src/tests/clipboard.rs index d8deda393..39c274460 100644 --- a/crates/ui-egui/src/tests/clipboard.rs +++ b/crates/ui-egui/src/tests/clipboard.rs @@ -176,3 +176,104 @@ fn the_layers_panel_menu_toggles_paste_remembers_layers() { assert!(app.session.active().unwrap().doc.paste_remembers_layers); assert!(item(&frame(&mut app, vec![])).0.starts_with('✓'), "checked"); } + +static TEST_CLIP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[test] +fn copying_text_carries_formatting_across_documents() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + // Document 1 + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id1 = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Styled Text", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); + let v = app.view_info(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id1, "start": 0, "end": 6})); + crate::panels::character::intercept_text_command(&mut app, "edit.copy"); + + // Document 2 + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id2 = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Other", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id2, "start": 5, "end": 5})); + crate::panels::character::intercept_text_command(&mut app, "edit.paste"); + let t2 = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id2)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t2) = &t2.kind else { panic!() }; + assert_eq!(t2.plain_text(), "OtherStyled"); + let pasted_run = &t2.runs[1]; + assert_eq!(pasted_run.style.size, 36.0, "pasted text keeps 36 pt size in document 2"); + assert_eq!( + pasted_run.style.fill, + vectorcraft_doc::color::Paint::solid(vectorcraft_doc::color::Color::rgb8(255, 0, 0)), + "pasted text keeps red fill" + ); +} + +#[test] +fn paste_without_formatting_takes_surrounding_style() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id1 = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Big Red", "size": 48, "color": "#ff0000"}))["id"].as_u64().unwrap(); + let v = app.view_info(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id1, "start": 0, "end": 7})); + crate::panels::character::intercept_text_command(&mut app, "edit.copy"); + + // Paste without formatting into another document with 10 pt text + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id2 = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Small: ", "size": 10, "color": "#000000"}))["id"].as_u64().unwrap(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id2, "start": 7, "end": 7})); + crate::panels::character::intercept_text_command(&mut app, "edit.pasteWithoutFormatting"); + let t2 = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id2)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t2) = &t2.kind else { panic!() }; + assert_eq!(t2.plain_text(), "Small: Big Red"); + assert!(t2.runs.iter().all(|r| r.style.size == 10.0), "all runs take 10 pt size: {:?}", t2.runs); +} + +/// https://helpx.adobe.com/illustrator/using/format-text.html +#[test] +fn copying_single_character_carries_formatting_across_documents() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id1 = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "X", "size": 36, "color": "#ff0000"}))["id"].as_u64().unwrap(); + let v = app.view_info(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id1, "start": 0, "end": 1})); + crate::panels::character::intercept_text_command(&mut app, "edit.copy"); + + // Document 2 + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id2 = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "A", "size": 12, "color": "#000000"}))["id"].as_u64().unwrap(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id2, "start": 1, "end": 1})); + crate::panels::character::intercept_text_command(&mut app, "edit.paste"); + let t2 = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id2)).unwrap(); + let vectorcraft_doc::NodeKind::Text(t2) = &t2.kind else { panic!() }; + assert_eq!(t2.plain_text(), "AX"); + assert_eq!(t2.runs[1].style.size, 36.0, "single character pasted carries styled size"); +} + +#[test] +fn copying_text_and_pasting_on_canvas_in_another_document() { + let _guard = TEST_CLIP_LOCK.lock().unwrap(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + let id1 = run(&mut app, "text.create", json!({"x": 20, "y": 40, "text": "Hello", "size": 28, "color": "#00ff00"}))["id"].as_u64().unwrap(); + let v = app.view_info(); + app.session.select_tool("type", v).unwrap(); + app.session.set_tool_option("textSelection", &json!({"id": id1, "start": 0, "end": 5})); + crate::panels::character::intercept_text_command(&mut app, "edit.copy"); + + // Document 2: paste onto canvas with selection tool + run(&mut app, "file.new", json!({"width": 200, "height": 200})); + app.run("edit.paste", json!({})).unwrap(); + let pasted_nodes = objects(&app); + assert_eq!(pasted_nodes.len(), 1); + let node = app.session.doc().unwrap().doc.node(pasted_nodes[0]).unwrap(); + let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!("not text") }; + assert_eq!(t.plain_text(), "Hello"); + assert_eq!(t.runs[0].style.size, 28.0); +}