From e6c0f92d59226953eec7aed841bd186f9a178427 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:30:35 +0300 Subject: [PATCH] engine: a cut takes the source's notes, hyperlinks and merged areas with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cut removed the source's cells and pictures and then re-created the block's merged areas at the destination and copied its notes there — but removed nothing from the source, so one merged block became two (an empty one left behind) and a note ended up on both the old and the new cell. Excel moves both and leaves the source clean. The cut now clears the source range's comments, hyperlinks and merges the same way edit.clear does. A second gap in the same path: paste never transferred hyperlinks at all, so clearing the source would have lost them — paste now carries hyperlinks (in What::All and a new What::Hyperlinks, so edit.pasteSpecial {"what":"hyperlinks"} works like "comments"). Regression test: a_cut_moves_the_sources_notes_hyperlinks_and_merges — on main the old merge stays behind and the note is on both cells; with the fix the merge, note and link all move and the source is clean. --- crates/engine/src/cmd/edit.rs | 21 ++++++++++++++++++++- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) 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).