diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..0ae044b5c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -769,6 +769,7 @@ enum What { Values, Formats, Comments, + Hyperlinks, Validation, AllExceptBorders, ColumnWidths, @@ -903,6 +904,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { "values" => What::Values, "formats" => What::Formats, "comments" | "notes" => What::Comments, + "hyperlinks" | "links" => What::Hyperlinks, "validation" => What::Validation, "allExceptBorders" => What::AllExceptBorders, "columnWidths" => What::ColumnWidths, @@ -952,6 +954,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { let source = cx.sheet_mut(clip.sheet)?; moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect(); let taken = source.take_cells(src); + // Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them + // with the block: the destination is written from the clipboard snapshot taken + // before the cut, so removing them here loses nothing and leaves the old place + // clean instead of leaving one merged block and one note behind. + source.comments.retain(|c, _| !src.contains(*c)); + source.hyperlinks.retain(|c, _| !src.contains(*c)); + source.merges.retain(|m| !src.contains_range(m)); for (c, _) in &taken { cx.touch(clip.sheet, *c); } @@ -1048,7 +1057,7 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { } } What::Formats => new.style = map_style(sc_cell.style, &mut cx.wb), - What::Comments | What::Validation | What::ColumnWidths => {} + What::Comments | What::Hyperlinks | What::Validation | What::ColumnWidths => {} } // Operations combine numbers with what is already there. if op != "none" @@ -1121,6 +1130,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } } + // A cut moves the block's hyperlinks with it, so they must reach the + // destination too (only `edit.clear` touched them before). + What::Hyperlinks => { + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } + } What::ColumnWidths => { if let Some(info) = src_sheet.cols.get(&sc.col).copied() { cx.sheet_mut(dest_sheet)?.cols.insert(dest.col, info); @@ -1130,6 +1146,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { if let Some(cm) = src_sheet.comments.get(&sc).cloned() { cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm); } + if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() { + cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h); + } cx.sheet_mut(dest_sheet)?.set_cell(dest, new); } _ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..5c18b4ecf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2925,6 +2925,36 @@ fn notes_comments_and_links_survive_a_json_save() { assert_eq!(after.cells, before.cells); } +/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and +/// pictures were removed but the block's merges and notes were not, so one merged block became +/// two (an empty one left at the old place) and a note ended up on both the old and the new +/// cell. Hyperlinks were not transferred at all. +#[test] +fn a_cut_moves_the_sources_notes_hyperlinks_and_merges() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "input": "y"})).unwrap(); + s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Example"})).unwrap(); + s.execute("insert.link", json!({"cell": "A1", "target": "https://example.com"})).unwrap(); + s.execute("home.mergeCells", json!({"range": "A1:B1"})).unwrap(); + + s.execute("edit.cut", json!({"range": "A1:B1"})).unwrap(); + s.execute("edit.paste", json!({"at": "D1"})).unwrap(); + + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let (a1, d1) = (CellRef::parse("A1").unwrap(), CellRef::parse("D1").unwrap()); + assert_eq!(sh.merges, vec![gridcraft_core::RangeRef::parse("D1:E1").unwrap()], "the old merge must not stay behind"); + assert!(!sh.comments.contains_key(&a1), "the note moved"); + assert!(sh.comments.contains_key(&d1), "the note is at the new cell"); + assert!(!sh.hyperlinks.contains_key(&a1), "the hyperlink moved"); + assert!(sh.hyperlinks.contains_key(&d1), "the hyperlink is at the new cell"); + // The cells themselves moved as before; E1 is covered by the pasted merge (merging + // A1:B1 cleared B1, so only the anchor's content travels). + assert_eq!(v(&s, "D1"), Value::text("x")); + assert!(v(&s, "A1").is_empty() && v(&s, "B1").is_empty()); +} + /// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is /// a place in this document, anything else an existing file or web page. A file saved as a /// `location` opens nothing in Excel (#800).