mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 20:27:05 -06:00
Fix #4459: retain text properties when copying text across documents
This commit is contained in:
committed by
bflatastic
parent
8207632afe
commit
46cc03b50a
@@ -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))
|
||||
|
||||
@@ -418,6 +418,7 @@ static CTX: OnceLock<egui::Context> = 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<String> = Mutex::new(String::new());
|
||||
static CLIP_RUNS: Mutex<Option<Value>> = 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::<Vec<vectorcraft_doc::TextRun>>(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<String>) {
|
||||
let s = s.unwrap_or_else(|| CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone());
|
||||
fn text_paste(app: &mut VectorcraftApp, s: Option<String>, 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::<Vec<vectorcraft_doc::TextRun>>(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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user