Skip to main content

ironcalc_base/user_model/
clipboard.rs

1#![deny(missing_docs)]
2
3use std::borrow::Cow;
4use std::{
5    collections::{HashMap, HashSet},
6    io::Cursor,
7};
8
9use csv::{ReaderBuilder, WriterBuilder};
10use serde::{Deserialize, Serialize};
11
12use crate::{
13    cf_types::ConditionalFormatting,
14    expressions::types::{Area, CellReferenceIndex},
15    model::CellStructure,
16    types::{ArrayKind, Cell, Link, MergedCell, Style},
17    UserModel,
18};
19
20use crate::user_model::history::Diff;
21
22const PARTIAL_MERGE_PASTE_ERROR: &str =
23    "Cannot paste: a merged cell partially overlaps the paste area";
24
25/// Data for the clipboard
26pub type ClipboardData = HashMap<i32, HashMap<i32, ClipboardCell>>;
27
28pub type ClipboardTuple = (i32, i32, i32, i32);
29
30#[derive(Serialize, Deserialize)]
31pub struct ClipboardCell {
32    text: String,
33    is_spill: bool,
34    style: Style,
35    // the link attached to the cell, if any (`default` keeps older clipboard
36    // payloads without the field deserializable)
37    #[serde(default)]
38    link: Option<Link>,
39}
40
41#[derive(Serialize, Deserialize)]
42pub struct Clipboard {
43    pub(crate) csv: String,
44    pub(crate) data: ClipboardData,
45    pub(crate) sheet: u32,
46    pub(crate) range: (i32, i32, i32, i32),
47}
48
49impl<'a> UserModel<'a> {
50    // Applies the merge containment rule to a paste into `target_area`
51    // (whose top-left cell is the paste anchor). The paste footprint is the
52    // target rectangle plus `pasted_merges`, the translated merges of the
53    // copied area it will recreate — they can stick out of the target
54    // because the copied range is clamped to the sheet dimension while a
55    // merge always travels whole.
56    //
57    // * a merged cell fully contained in the footprint is removed — the
58    //   paste replaces it. The removal diff is pushed before the cell diffs
59    //   so a forward replay writes on unmerged cells; the merges the paste
60    //   creates get their own diff afterwards, so an undo unmerges them
61    //   first.
62    // * a single-cell paste into the anchor of a merged cell keeps the
63    //   merge (only the anchor is written);
64    // * any other overlap rejects the paste, before anything changed.
65    fn remove_merges_replaced_by_paste(
66        &mut self,
67        target_area: &Area,
68        pasted_merges: &[MergedCell],
69        diff_list: &mut Vec<Diff>,
70    ) -> Result<(), String> {
71        let sheet = target_area.sheet;
72        let old_merged_cells = self.model.get_merged_cells(sheet)?.to_vec();
73        let single_cell_target = target_area.width == 1 && target_area.height == 1;
74        let cell_in_footprint = |row: i32, column: i32| -> bool {
75            (row >= target_area.row
76                && row < target_area.row + target_area.height
77                && column >= target_area.column
78                && column < target_area.column + target_area.width)
79                || pasted_merges.iter().any(|p| {
80                    row >= p.row
81                        && row < p.row + p.height
82                        && column >= p.column
83                        && column < p.column + p.width
84                })
85        };
86        let mut merged_cells_after_removal = Vec::new();
87        let mut removed_any = false;
88        for m in &old_merged_cells {
89            let intersects_footprint = m.intersects(
90                target_area.row,
91                target_area.column,
92                target_area.width,
93                target_area.height,
94            ) || pasted_merges
95                .iter()
96                .any(|p| m.intersects(p.row, p.column, p.width, p.height));
97            if !intersects_footprint {
98                merged_cells_after_removal.push(*m);
99                continue;
100            }
101            let mut contained = true;
102            'cells: for row in m.row..m.row + m.height {
103                for column in m.column..m.column + m.width {
104                    if !cell_in_footprint(row, column) {
105                        contained = false;
106                        break 'cells;
107                    }
108                }
109            }
110            if contained {
111                // replaced by the paste
112                removed_any = true;
113            } else if single_cell_target
114                && (m.row, m.column) == (target_area.row, target_area.column)
115            {
116                // pasting a single cell into a merged cell writes its anchor
117                merged_cells_after_removal.push(*m);
118            } else {
119                return Err(PARTIAL_MERGE_PASTE_ERROR.to_string());
120            }
121        }
122        if removed_any {
123            self.model.workbook.worksheet_mut(sheet)?.merged_cells =
124                merged_cells_after_removal.clone();
125            diff_list.push(Diff::SetMergedCells {
126                sheet,
127                old_value: old_merged_cells,
128                new_value: merged_cells_after_removal,
129            });
130        }
131        Ok(())
132    }
133
134    /// Returns a copy of the selected area
135    pub fn copy_to_clipboard(&self) -> Result<Clipboard, String> {
136        let selected_area = self.get_selected_view();
137        let sheet = selected_area.sheet;
138        let mut wtr = WriterBuilder::new().delimiter(b'\t').from_writer(vec![]);
139
140        let mut data = HashMap::new();
141        let [row_start, column_start, row_end, column_end] = selected_area.range;
142        let dimension = self.model.workbook.worksheet(sheet)?.dimension();
143        let row_end = row_end.min(dimension.max_row).max(row_start);
144        let column_end = column_end.min(dimension.max_column).max(column_start);
145        for row in row_start..=row_end {
146            let mut data_row = HashMap::new();
147            let mut text_row = Vec::new();
148            for column in column_start..=column_end {
149                let text = self.get_formatted_cell_value(sheet, row, column)?;
150                let content = self.get_cell_content(sheet, row, column)?;
151                let style = self.model.get_style_for_cell(sheet, row, column)?;
152                let is_spill = matches!(
153                    self.model.get_cell_structure(sheet, row, column)?,
154                    CellStructure::SpillArray { .. } | CellStructure::SpillDynamic { .. }
155                );
156                let link = self.model.get_cell_link(sheet, row, column)?;
157                data_row.insert(
158                    column,
159                    ClipboardCell {
160                        text: content,
161                        is_spill,
162                        style,
163                        link,
164                    },
165                );
166                text_row.push(text);
167            }
168            wtr.write_record(text_row)
169                .map_err(|e| format!("Error while processing csv: {e}"))?;
170            data.insert(row, data_row);
171        }
172
173        let csv = String::from_utf8(
174            wtr.into_inner()
175                .map_err(|e| format!("Processing error: '{e}'"))?,
176        )
177        .map_err(|e| format!("Error converting from utf8: '{e}'"))?;
178
179        Ok(Clipboard {
180            csv: csv.trim().to_string(),
181            data,
182            sheet,
183            range: (row_start, column_start, row_end, column_end),
184        })
185    }
186
187    /// Paste text that we copied
188    pub fn paste_from_clipboard(
189        &mut self,
190        source_sheet: u32,
191        source_range: ClipboardTuple,
192        clipboard: &ClipboardData,
193        is_cut: bool,
194    ) -> Result<(), String> {
195        let mut diff_list = Vec::new();
196        let view = self.get_selected_view();
197        let (source_first_row, source_first_column, source_last_row, source_last_column) =
198            source_range;
199        let sheet = view.sheet;
200        // Paste is anchored at the selected cell (the range is normalized, so
201        // its start corner is not necessarily where the selection began)
202        let (selected_row, selected_column) = (view.row, view.column);
203        let mut max_row = selected_row;
204        let mut max_column = selected_column;
205        let area = &Area {
206            sheet,
207            row: source_first_row,
208            column: source_first_column,
209            width: source_last_column - source_first_column + 1,
210            height: source_last_row - source_first_row + 1,
211        };
212        let target_area = &Area {
213            sheet,
214            row: selected_row,
215            column: selected_column,
216            width: source_last_column - source_first_column + 1,
217            height: source_last_row - source_first_row + 1,
218        };
219
220        // The merges of the copied area, captured before the containment
221        // step below can remove target merges: when the paste overlaps its
222        // own source, a source merge can also be a replaced target merge and
223        // must still be recreated.
224        let source_merges: Vec<MergedCell> = self
225            .model
226            .get_merged_cells(source_sheet)?
227            .iter()
228            .filter(|m| {
229                m.intersects(
230                    source_first_row,
231                    source_first_column,
232                    source_last_column - source_first_column + 1,
233                    source_last_row - source_first_row + 1,
234                )
235            })
236            .copied()
237            .collect();
238
239        // Merged cells in the target are replaced by the paste (or reject it)
240        let pasted_merges: Vec<MergedCell> = source_merges
241            .iter()
242            .map(|m| MergedCell {
243                row: m.row + selected_row - source_first_row,
244                column: m.column + selected_column - source_first_column,
245                width: m.width,
246                height: m.height,
247            })
248            .collect();
249        self.remove_merges_replaced_by_paste(target_area, &pasted_merges, &mut diff_list)?;
250
251        let mut seen_cells = HashSet::new();
252        // Compute all changes
253        let mut changes = Vec::new();
254        for (source_row, data_row) in clipboard {
255            let delta_row = source_row - source_first_row;
256            let target_row = selected_row + delta_row;
257            max_row = max_row.max(target_row);
258            for (source_column, value) in data_row {
259                let delta_column = source_column - source_first_column;
260                let target_column = selected_column + delta_column;
261                max_column = max_column.max(target_column);
262
263                if value.is_spill {
264                    // Spill cells carry no formula/value, but their style should still be copied.
265                    let old_style =
266                        self.model
267                            .get_cell_style_or_none(sheet, target_row, target_column)?;
268                    changes.push((
269                        target_row,
270                        target_column,
271                        None,
272                        old_style,
273                        None,
274                        value.style.clone(),
275                    ));
276                    seen_cells.insert((target_row, target_column));
277                    continue;
278                }
279
280                // We are copying the value in
281                // (source_row, source_column) to (target_row , target_column)
282                // References in formulas are displaced
283
284                // remain in the copied area
285                let source = &CellReferenceIndex {
286                    sheet,
287                    column: *source_column,
288                    row: *source_row,
289                };
290                let target = &CellReferenceIndex {
291                    sheet,
292                    column: target_column,
293                    row: target_row,
294                };
295                let new_value = if is_cut {
296                    self.model
297                        .move_cell_value_to_area(&value.text, source, target, area)?
298                } else {
299                    self.model
300                        .extend_copied_value(&value.text, source, target)?
301                };
302
303                let old_value = self
304                    .model
305                    .workbook
306                    .worksheet(sheet)?
307                    .cell(target_row, target_column)
308                    .map(Cow::into_owned);
309
310                let old_style =
311                    self.model
312                        .get_cell_style_or_none(sheet, target_row, target_column)?;
313                changes.push((
314                    target_row,
315                    target_column,
316                    old_value.clone(),
317                    old_style.clone(),
318                    Some(new_value.clone()),
319                    value.style.clone(),
320                ));
321                seen_cells.insert((target_row, target_column));
322            }
323        }
324        // Clearing the target area also removes its links: capture them for undo
325        diff_list.extend(self.range_link_diffs(target_area)?);
326        // clear the whole area (this resets array formulas)
327        self.model.range_clear_contents(target_area)?;
328        // set the new values and styles
329        for (target_row, target_column, old_value, old_style, new_value, style) in changes {
330            if let Some(ref v) = new_value {
331                self.model
332                    .set_user_input(sheet, target_row, target_column, v.clone())?;
333                diff_list.push(Diff::SetCellValue {
334                    sheet,
335                    row: target_row,
336                    column: target_column,
337                    new_value: v.clone(),
338                    old_value: Box::new(old_value),
339                });
340            }
341            self.model
342                .set_cell_style(sheet, target_row, target_column, &style)?;
343
344            diff_list.push(Diff::SetCellStyle {
345                sheet,
346                row: target_row,
347                column: target_column,
348                old_value: Box::new(old_style),
349                new_value: Box::new(style),
350            });
351        }
352        // Paste the links of the copied cells. This runs after the values are
353        // set so that it also overrides any link auto-created by an URL value.
354        for (source_row, data_row) in clipboard {
355            let target_row = selected_row + (source_row - source_first_row);
356            for (source_column, value) in data_row {
357                let target_column = selected_column + (source_column - source_first_column);
358                let old_link = self.model.get_cell_link(sheet, target_row, target_column)?;
359                if old_link == value.link {
360                    continue;
361                }
362                match &value.link {
363                    Some(link) => {
364                        self.model
365                            .set_cell_link(sheet, target_row, target_column, link.clone())?
366                    }
367                    None => self
368                        .model
369                        .delete_cell_link(sheet, target_row, target_column)?,
370                }
371                diff_list.push(Diff::SetCellLink {
372                    sheet,
373                    row: target_row,
374                    column: target_column,
375                    old_value: Box::new(old_link),
376                    new_value: Box::new(value.link.clone()),
377                });
378            }
379        }
380        if is_cut {
381            // A cut moves the merged cells of the source area to the target:
382            // they are removed from the source and recreated, translated by
383            // the paste offset. A merge is matched by intersection: it can
384            // stick out of the cut range when its covered cells are empty (the
385            // copied range is clamped to the sheet dimension), and it moves
386            // whole.
387            let old_source_merged_cells = self.model.get_merged_cells(source_sheet)?.to_vec();
388            let old_target_merged_cells = self.model.get_merged_cells(sheet)?.to_vec();
389            self.model.unmerge_cells(&Area {
390                sheet: source_sheet,
391                row: source_first_row,
392                column: source_first_column,
393                width: source_last_column - source_first_column + 1,
394                height: source_last_row - source_first_row + 1,
395            })?;
396            for m in source_merges {
397                let merge_area = Area {
398                    sheet,
399                    row: m.row + selected_row - source_first_row,
400                    column: m.column + selected_column - source_first_column,
401                    width: m.width,
402                    height: m.height,
403                };
404                // Capture the content and links the merge will clear: a merge
405                // sticking out of the pasted block can swallow cells that were
406                // not part of the paste
407                let mut merge_diffs = self.covered_cells_clear_diffs(&merge_area)?;
408                // A translated merge can conflict outside the checked target
409                // area (another merge, an array formula) or fall off the
410                // sheet: those are dropped, the rest of the cut still lands
411                // merge_cells_keep_styles: the pasted cells already carry the
412                // merged style pattern of the source; stamping the anchor's
413                // style would drop the perimeter borders of non-anchor cells
414                if self.model.merge_cells_keep_styles(&merge_area).is_ok() {
415                    diff_list.append(&mut merge_diffs);
416                }
417            }
418            // One snapshot per touched sheet (source and target coincide on a
419            // same-sheet cut)
420            let mut snapshot_sheets = vec![source_sheet];
421            if sheet != source_sheet {
422                snapshot_sheets.push(sheet);
423            }
424            for (snapshot_sheet, old_value) in snapshot_sheets
425                .into_iter()
426                .zip([old_source_merged_cells, old_target_merged_cells])
427            {
428                let new_value = self.model.get_merged_cells(snapshot_sheet)?.to_vec();
429                if old_value != new_value {
430                    diff_list.push(Diff::SetMergedCells {
431                        sheet: snapshot_sheet,
432                        old_value,
433                        new_value,
434                    });
435                }
436            }
437            for row in source_first_row..=source_last_row {
438                for column in source_first_column..=source_last_column {
439                    if (source_sheet == sheet) && seen_cells.contains(&(row, column)) {
440                        continue;
441                    }
442                    let old_value = self
443                        .model
444                        .workbook
445                        .worksheet(source_sheet)?
446                        .cell(row, column)
447                        .map(Cow::into_owned);
448
449                    diff_list.push(Diff::RangeClearContents {
450                        sheet: source_sheet,
451                        row,
452                        column,
453                        width: 1,
454                        height: 1,
455                        old_value: vec![vec![old_value.clone()]],
456                    });
457
458                    // a cut also moves the link away from the source cell
459                    let old_link = self.model.get_cell_link(source_sheet, row, column)?;
460                    if let Some(old_link) = old_link {
461                        self.model.delete_cell_link(source_sheet, row, column)?;
462                        diff_list.push(Diff::SetCellLink {
463                            sheet: source_sheet,
464                            row,
465                            column,
466                            old_value: Box::new(Some(old_link)),
467                            new_value: Box::new(None),
468                        });
469                    }
470
471                    // If the source is a dynamic formula anchor, range_clear_contents
472                    // would erase its entire spill — including cells that were just
473                    // written to by this paste.  Clear the anchor and its spill cells
474                    // individually instead, skipping any paste-target cells.
475                    let spill_dims = match &old_value {
476                        Some(Cell::ArrayFormula {
477                            kind: ArrayKind::Dynamic,
478                            r,
479                            ..
480                        }) => Some(*r),
481                        _ => None,
482                    };
483                    if let Some((spill_w, spill_h)) = spill_dims {
484                        let ws = self.model.workbook.worksheet_mut(source_sheet)?;
485                        for sr in row..row + spill_h {
486                            for sc in column..column + spill_w {
487                                if (source_sheet == sheet) && seen_cells.contains(&(sr, sc)) {
488                                    continue;
489                                }
490                                let _ = ws.cell_clear_contents(sr, sc);
491                            }
492                        }
493                    } else {
494                        let area = Area {
495                            sheet: source_sheet,
496                            row,
497                            column,
498                            width: 1,
499                            height: 1,
500                        };
501                        self.model.range_clear_contents(&area)?;
502                    }
503                    let old_style = self
504                        .model
505                        .get_cell_style_or_none(source_sheet, row, column)?;
506                    let default_style = Style::default();
507                    self.model
508                        .set_cell_style(source_sheet, row, column, &default_style)?;
509                    diff_list.push(Diff::SetCellStyle {
510                        sheet: source_sheet,
511                        row,
512                        column,
513                        old_value: Box::new(old_style),
514                        new_value: Box::new(default_style),
515                    });
516                }
517            }
518            // Update external formulas that reference cells in the moved area.
519            // source_sheet is used here (not `sheet`) so cross-sheet paste works.
520            let ext_area = Area {
521                sheet: source_sheet,
522                row: source_first_row,
523                column: source_first_column,
524                width: source_last_column - source_first_column + 1,
525                height: source_last_row - source_first_row + 1,
526            };
527            let ext_updates = self.model.get_external_formula_updates_for_cut(
528                &ext_area,
529                selected_row,
530                selected_column,
531            )?;
532            for (ext_sheet, ext_row, ext_col, new_formula) in ext_updates {
533                let old_cell = self
534                    .model
535                    .workbook
536                    .worksheet(ext_sheet)?
537                    .cell(ext_row, ext_col)
538                    .map(Cow::into_owned);
539                self.model
540                    .set_user_input(ext_sheet, ext_row, ext_col, new_formula.clone())?;
541                diff_list.push(Diff::SetCellValue {
542                    sheet: ext_sheet,
543                    row: ext_row,
544                    column: ext_col,
545                    new_value: new_formula,
546                    old_value: Box::new(old_cell),
547                });
548            }
549            // Update defined names whose references land inside the moved area.
550            let dn_updates = self.model.get_defined_name_updates_for_cut(
551                &ext_area,
552                selected_row,
553                selected_column,
554            );
555            for (dn_name, dn_scope, old_formula, new_formula) in dn_updates {
556                diff_list.push(Diff::UpdateDefinedName {
557                    name: dn_name.clone(),
558                    scope: dn_scope,
559                    old_formula: old_formula.clone(),
560                    new_name: dn_name.clone(),
561                    new_scope: dn_scope,
562                    new_formula: new_formula.clone(),
563                });
564                self.model.update_defined_name(
565                    &dn_name,
566                    dn_scope,
567                    &dn_name,
568                    dn_scope,
569                    &new_formula,
570                )?;
571            }
572            // Update conditional formatting ranges and formula references.
573            let cf_updates = self.model.get_conditional_formatting_updates_for_cut(
574                &ext_area,
575                selected_row,
576                selected_column,
577            )?;
578            for (cf_sheet, cf_idx, new_range, new_rule) in cf_updates {
579                let old_cf = self
580                    .model
581                    .workbook
582                    .worksheet(cf_sheet)?
583                    .conditional_formatting
584                    .get(cf_idx)
585                    .ok_or_else(|| format!("CF index {cf_idx} not found"))?
586                    .clone();
587                {
588                    let ws = self.model.workbook.worksheet_mut(cf_sheet)?;
589                    ws.conditional_formatting[cf_idx].range = new_range.clone();
590                    ws.conditional_formatting[cf_idx].cf_rule = new_rule.clone();
591                }
592                diff_list.push(Diff::UpdateConditionalFormatting {
593                    sheet: cf_sheet,
594                    index: cf_idx as u32,
595                    old_range: old_cf.range,
596                    old_rule: Box::new(old_cf.cf_rule),
597                    old_priority: old_cf.priority,
598                    new_range,
599                    new_rule: Box::new(new_rule),
600                });
601            }
602        } else {
603            // Copy-paste recreates the merged cells of the copied area at the
604            // target. A merge is matched by intersection: it can stick out of
605            // the copied range when its covered cells are empty (the copied
606            // range is clamped to the sheet dimension), and is recreated whole.
607            if !source_merges.is_empty() {
608                let old_merged_cells = self.model.get_merged_cells(sheet)?.to_vec();
609                for m in source_merges {
610                    let merge_area = Area {
611                        sheet,
612                        row: m.row + selected_row - source_first_row,
613                        column: m.column + selected_column - source_first_column,
614                        width: m.width,
615                        height: m.height,
616                    };
617                    // Capture the content and links the merge will clear: a
618                    // merge sticking out of the pasted block can swallow cells
619                    // that were not part of the paste
620                    let mut merge_diffs = self.covered_cells_clear_diffs(&merge_area)?;
621                    // A translated merge can conflict outside the checked
622                    // target area (another merge, an array formula) or fall
623                    // off the sheet: those are skipped, the rest still paste
624                    // merge_cells_keep_styles: the pasted cells already carry
625                    // the merged style pattern of the source; stamping the
626                    // anchor's style would drop the perimeter borders of
627                    // non-anchor cells
628                    if self.model.merge_cells_keep_styles(&merge_area).is_ok() {
629                        diff_list.append(&mut merge_diffs);
630                    }
631                }
632                let new_merged_cells = self.model.get_merged_cells(sheet)?.to_vec();
633                if old_merged_cells != new_merged_cells {
634                    diff_list.push(Diff::SetMergedCells {
635                        sheet,
636                        old_value: old_merged_cells,
637                        new_value: new_merged_cells,
638                    });
639                }
640            }
641
642            // Copy-paste: duplicate CF rules from the source area to the target.
643            let cf_copies = self.model.get_cf_rules_to_copy(
644                source_sheet,
645                source_first_row,
646                source_first_column,
647                source_last_row,
648                source_last_column,
649                selected_row,
650                selected_column,
651            );
652            for (new_range, new_rule) in cf_copies {
653                let priority = self
654                    .model
655                    .workbook
656                    .worksheet(sheet)?
657                    .conditional_formatting
658                    .iter()
659                    .map(|cf| cf.priority)
660                    .max()
661                    .map(|m| m + 1)
662                    .unwrap_or(1);
663                self.model
664                    .workbook
665                    .worksheet_mut(sheet)?
666                    .conditional_formatting
667                    .push(ConditionalFormatting {
668                        range: new_range.clone(),
669                        cf_rule: new_rule.clone(),
670                        priority,
671                    });
672                diff_list.push(Diff::AddConditionalFormatting {
673                    sheet,
674                    range: new_range,
675                    rule: Box::new(new_rule),
676                    priority,
677                });
678            }
679        }
680        self.push_diff_list(diff_list);
681        // select the pasted area
682        self.set_selected_range(selected_row, selected_column, max_row, max_column)?;
683        self.evaluate_if_not_paused();
684        Ok(())
685    }
686
687    /// Paste a csv-string into the model
688    pub fn paste_csv_string(&mut self, area: &Area, csv: &str) -> Result<(), String> {
689        let sheet = area.sheet;
690
691        // First pass: parse all records so we know the full extent before touching any cells.
692        let mut records: Vec<Vec<String>> = Vec::new();
693        let mut max_width: i32 = 0;
694        let csv_reader = Cursor::new(csv);
695        let mut reader = ReaderBuilder::new()
696            .delimiter(b'\t')
697            .has_headers(false)
698            .from_reader(csv_reader);
699        for r in reader.records().flatten() {
700            let row_data: Vec<String> = r.iter().map(|v| v.to_string()).collect();
701            max_width = max_width.max(row_data.len() as i32);
702            records.push(row_data);
703        }
704        if records.is_empty() {
705            return Ok(());
706        }
707
708        // Check whether any static array formula would be partially overwritten.
709        let paste_area = Area {
710            sheet,
711            row: area.row,
712            column: area.column,
713            width: max_width,
714            height: records.len() as i32,
715        };
716
717        // Merged cells in the target are replaced by the paste (or reject
718        // it); plain text carries no merges, so contained ones just go
719        let mut diff_list = Vec::new();
720        self.remove_merges_replaced_by_paste(&paste_area, &[], &mut diff_list)?;
721
722        // Capture old values BEFORE clearing so undo can restore them correctly.
723        let mut old_values: HashMap<(i32, i32), Option<Cell>> = HashMap::new();
724        {
725            let ws = self.model.workbook.worksheet(sheet)?;
726            for r in area.row..area.row + records.len() as i32 {
727                for c in area.column..area.column + max_width {
728                    old_values.insert((r, c), ws.cell(r, c).map(Cow::into_owned));
729                }
730            }
731        }
732
733        // Clearing the target area also removes its links: capture them for undo
734        diff_list.extend(self.range_link_diffs(&paste_area)?);
735        self.model.range_clear_contents(&paste_area)?;
736
737        // Second pass: write values and build diff list.
738        let mut row = area.row;
739        let mut last_column = area.column;
740        for row_data in &records {
741            let mut column = area.column;
742            for value in row_data {
743                let old_value = old_values.remove(&(row, column)).unwrap_or(None);
744                diff_list.push(Diff::SetCellValue {
745                    sheet,
746                    row,
747                    column,
748                    new_value: value.to_string(),
749                    old_value: Box::new(old_value),
750                });
751                // pasted URLs are auto-linked: capture the link and style diffs too
752                self.set_user_input_with_link_diffs(
753                    sheet,
754                    row,
755                    column,
756                    value.to_string(),
757                    &mut diff_list,
758                )?;
759                column += 1;
760            }
761            last_column = last_column.max(column - 1);
762            row += 1;
763        }
764        self.push_diff_list(diff_list);
765        // select the pasted area
766        self.set_selected_range(area.row, area.column, row - 1, last_column)?;
767        self.evaluate_if_not_paused();
768        Ok(())
769    }
770}