diff --git a/Radzen.Blazor.Tests/Spreadsheet/PasteFidelityTests.cs b/Radzen.Blazor.Tests/Spreadsheet/PasteFidelityTests.cs new file mode 100644 index 000000000..83add36e8 --- /dev/null +++ b/Radzen.Blazor.Tests/Spreadsheet/PasteFidelityTests.cs @@ -0,0 +1,374 @@ +using Xunit; +using Radzen.Documents.Spreadsheet; + +namespace Radzen.Blazor.Spreadsheet.Tests; + +#nullable enable + +// Excel-parity cell transfer: paste, cut and autofill carry the full cell state +// (value, formula, format, quote prefix, hyperlink) and undo/redo restore it symmetrically. +public class PasteFidelityTests +{ + private static (Worksheet sheet, SpreadsheetClipboard clipboard, UndoRedoStack stack) Setup() + { + var sheet = new Worksheet(20, 5); + return (sheet, new SpreadsheetClipboard(), new UndoRedoStack(sheet)); + } + + private static string CopyRange(Worksheet sheet, SpreadsheetClipboard clipboard, string range) + { + sheet.Selection.Select(RangeRef.Parse(range)); + clipboard.Copy(sheet); + return sheet.GetDelimitedString(RangeRef.Parse(range)); + } + + private static string CutRange(Worksheet sheet, SpreadsheetClipboard clipboard, string range) + { + sheet.Selection.Select(RangeRef.Parse(range)); + clipboard.Cut(sheet); + return sheet.GetDelimitedString(RangeRef.Parse(range)); + } + + [Fact] + public void Paste_CopiesFormatQuotePrefixAndHyperlink() + { + var (sheet, clipboard, stack) = Setup(); + var src = sheet.Cells[0, 0]; + src.SetValue("'0123"); + src.Format.Bold = true; + src.Format.BackgroundColor = "#FF0000"; + src.Hyperlink = new Hyperlink { Url = "https://radzen.com", Text = "Radzen" }; + + var text = CopyRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + + var dst = sheet.Cells[0, 2]; + Assert.Equal("0123", dst.Value); + Assert.True(dst.QuotePrefix); + Assert.True(dst.FormatOrNull?.Bold); + Assert.Equal("#FF0000", dst.FormatOrNull?.BackgroundColor); + Assert.Equal("https://radzen.com", dst.Hyperlink?.Url); + } + + [Fact] + public void Paste_ValueOverFormulaCell_ClearsFormula() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = 5d; + sheet.Cells[0, 2].Formula = "=A1*2"; + + var text = CopyRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + + Assert.Null(sheet.Cells[0, 2].Formula); + Assert.Equal(5d, sheet.Cells[0, 2].Value); + + sheet.Cells[0, 0].Value = 9d; // recalc must not overwrite the pasted value + Assert.Equal(5d, sheet.Cells[0, 2].Value); + } + + [Fact] + public void Paste_UnformattedSource_ClearsDestinationFormat() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = "plain"; + sheet.Cells[0, 2].Value = "styled"; + sheet.Cells[0, 2].Format.Bold = true; + + var text = CopyRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + + Assert.Equal("plain", sheet.Cells[0, 2].Value); + Assert.Null(sheet.Cells[0, 2].FormatOrNull); + } + + [Fact] + public void Paste_UnpopulatedSourceCell_BlanksPopulatedDestination() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = "A"; + // A2 stays unpopulated + sheet.Cells[0, 2].Value = "old"; + sheet.Cells[1, 2].Value = "stale"; + sheet.Cells[1, 2].Format.Bold = true; + sheet.Cells[1, 2].Hyperlink = new Hyperlink { Url = "https://x" }; + + var text = CopyRange(sheet, clipboard, "A1:A2"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + + Assert.Equal("A", sheet.Cells[0, 2].Value); + Assert.Null(sheet.Cells[1, 2].Value); + Assert.Null(sheet.Cells[1, 2].FormatOrNull); + Assert.Null(sheet.Cells[1, 2].Hyperlink); + } + + [Fact] + public void Paste_UnpopulatedSourceOverUnpopulatedDestination_DoesNotMaterialize() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = "A"; + + var text = CopyRange(sheet, clipboard, "A1:A2"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + + Assert.False(sheet.Cells.TryGet(1, 2, out _)); + } + + [Fact] + public void TiledPaste_CopiesFormatsPerTile() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = "X"; + sheet.Cells[0, 0].Format.Bold = true; + + var text = CopyRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C3"), text)); + + for (var row = 0; row <= 2; row++) + { + Assert.Equal("X", sheet.Cells[row, 2].Value); + Assert.True(sheet.Cells[row, 2].FormatOrNull?.Bold); + } + } + + [Fact] + public void Cut_ClearsSourceAndTransfersEverything() + { + var (sheet, clipboard, stack) = Setup(); + var src = sheet.Cells[0, 0]; + src.SetValue("'007"); + src.Format.Bold = true; + src.Hyperlink = new Hyperlink { Url = "https://radzen.com" }; + + var text = CutRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + + Assert.Null(src.Value); + Assert.Null(src.FormatOrNull); + Assert.False(src.QuotePrefix); + Assert.Null(src.Hyperlink); + + var dst = sheet.Cells[0, 2]; + Assert.Equal("007", dst.Value); + Assert.True(dst.QuotePrefix); + Assert.True(dst.FormatOrNull?.Bold); + Assert.Equal("https://radzen.com", dst.Hyperlink?.Url); + } + + [Fact] + public void SetValue_PlainValueOnFormulaCell_ClearsFormula() + { + var sheet = new Worksheet(20, 5); + sheet.Cells[0, 0].Value = 2d; + sheet.Cells[0, 1].Formula = "=A1*3"; + Assert.Equal(6d, sheet.Cells[0, 1].Value); + + sheet.Cells[0, 1].SetValue("42"); + + Assert.Null(sheet.Cells[0, 1].Formula); + Assert.Equal(42d, sheet.Cells[0, 1].Value); + + sheet.Cells[0, 0].Value = 10d; // recalc must not resurrect the formula + Assert.Equal(42d, sheet.Cells[0, 1].Value); + } + + [Fact] + public void SetValue_QuotePrefix_StoresLiteralTextWithoutTypeInference() + { + var sheet = new Worksheet(20, 5); + sheet.Cells[0, 0].SetValue("'0123"); + + Assert.Equal("0123", sheet.Cells[0, 0].Value); + Assert.Equal(CellDataType.String, sheet.Cells[0, 0].ValueType); + Assert.True(sheet.Cells[0, 0].QuotePrefix); + Assert.Equal("'0123", sheet.Cells[0, 0].GetValue()); + } + + [Fact] + public void TypedString_SurvivesCaptureAndUndo() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 2].Data = CellData.FromString("0123"); // explicitly String-typed, like CsvReader with ParseValues=false + sheet.Cells[0, 0].Value = "other"; + + var text = CopyRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + stack.Undo(); + + Assert.Equal("0123", sheet.Cells[0, 2].Value); + Assert.Equal(CellDataType.String, sheet.Cells[0, 2].ValueType); + } + + [Fact] + public void Undo_PasteFormulaOverEmpty_LeavesCellEmpty() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = 1d; + sheet.Cells[1, 0].Value = 2d; + sheet.Cells[2, 0].Formula = "=SUM(A1:A2)"; + + var text = CopyRange(sheet, clipboard, "A3:A3"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C3:C3"), text)); + Assert.Equal("=SUM(C1:C2)", sheet.Cells[2, 2].Formula); + + stack.Undo(); + + Assert.Null(sheet.Cells[2, 2].Formula); + Assert.Null(sheet.Cells[2, 2].Value); + } + + [Fact] + public void Undo_PasteFormat_RestoresPriorState() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = "X"; + sheet.Cells[0, 0].Format.Bold = true; + sheet.Cells[0, 2].Value = "plain"; // populated, unformatted + + var text = CopyRange(sheet, clipboard, "A1:A2"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + Assert.True(sheet.Cells[0, 2].FormatOrNull?.Bold); + + stack.Undo(); + + Assert.Equal("plain", sheet.Cells[0, 2].Value); + Assert.Null(sheet.Cells[0, 2].FormatOrNull); + Assert.False(sheet.Cells.TryGet(1, 2, out _)); // unpopulated destination stays unmaterialized + } + + [Fact] + public void RedoUndo_SnapshotIsolatedFromLaterEdits() + { + var (sheet, clipboard, stack) = Setup(); + sheet.Cells[0, 0].Value = "X"; + sheet.Cells[0, 0].Format.Bold = true; + sheet.Cells[0, 2].Value = "orig"; + sheet.Cells[0, 2].Format.Italic = true; + + var text = CopyRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + stack.Undo(); + + sheet.Cells[0, 2].Format.Underline = true; // must not leak into any snapshot + + stack.Redo(); + Assert.True(sheet.Cells[0, 2].FormatOrNull?.Bold); + Assert.False(sheet.Cells[0, 2].FormatOrNull?.Underline); + + stack.Undo(); + Assert.True(sheet.Cells[0, 2].FormatOrNull?.Italic); + Assert.False(sheet.Cells[0, 2].FormatOrNull?.Bold); + } + + [Fact] + public void CutUndoRedo_RestoresAndReclearsSource() + { + var (sheet, clipboard, stack) = Setup(); + var src = sheet.Cells[0, 0]; + src.SetValue("'A"); + src.Format.Bold = true; + src.Hyperlink = new Hyperlink { Url = "https://x" }; + + var text = CutRange(sheet, clipboard, "A1:A1"); + stack.Execute(new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text)); + + stack.Undo(); + Assert.Equal("A", src.Value); + Assert.True(src.QuotePrefix); + Assert.True(src.FormatOrNull?.Bold); + Assert.Equal("https://x", src.Hyperlink?.Url); + + stack.Redo(); + Assert.Null(src.Value); + Assert.Null(src.FormatOrNull); + Assert.Null(src.Hyperlink); + Assert.Equal("A", sheet.Cells[0, 2].Value); + } + + [Fact] + public void MergeUndo_OnUnpopulatedTopLeft_LeavesNoFormat() + { + var sheet = new Worksheet(20, 5); + var stack = new UndoRedoStack(sheet); + + stack.Execute(new MergeCellsCommand(sheet, RangeRef.Parse("C1:D2"), center: true)); + Assert.Equal(TextAlign.Center, sheet.Cells[0, 2].FormatOrNull?.TextAlign); + + stack.Undo(); + + Assert.False(sheet.Cells.TryGet(0, 2, out var cell) && cell.FormatOrNull is not null); + } + + [Fact] + public void SortUndo_RestoresQuotePrefixHyperlinkAndEmptiness() + { + var sheet = new Worksheet(20, 5); + var stack = new UndoRedoStack(sheet); + sheet.Cells[0, 0].SetValue("'B"); + sheet.Cells[0, 0].Hyperlink = new Hyperlink { Url = "https://b" }; + sheet.Cells[1, 0].SetValue("'A"); + // A3 unpopulated inside the sort range + + stack.Execute(new SortCommand(sheet, RangeRef.Parse("A1:A3"), SortOrder.Ascending, 0)); + Assert.Equal("A", sheet.Cells[0, 0].Value); + + stack.Undo(); + + Assert.Equal("B", sheet.Cells[0, 0].Value); + Assert.True(sheet.Cells[0, 0].QuotePrefix); + Assert.Equal("https://b", sheet.Cells[0, 0].Hyperlink?.Url); + Assert.True(!sheet.Cells.TryGet(2, 0, out var third) || third.IsEmpty); + } + + [Fact] + public void Autofill_FromUnformattedAndUnpopulatedSources_ClearsDestinationFormat() + { + var sheet = new Worksheet(20, 5); + sheet.Cells[0, 0].Value = "plain"; // populated, unformatted + // A2 unpopulated + sheet.Cells[2, 0].Value = "old"; + sheet.Cells[2, 0].Format.Bold = true; + sheet.Cells[3, 0].Value = "older"; + sheet.Cells[3, 0].Format.Bold = true; + + new AutofillCommand(sheet, RangeRef.Parse("A1:A2"), RangeRef.Parse("A1:A4"), AutofillDirection.Down).Execute(); + + Assert.Null(sheet.Cells[2, 0].FormatOrNull); + Assert.Null(sheet.Cells[3, 0].FormatOrNull); + } + + [Fact] + public void AutofillUndo_RemovesCopiedFormats() + { + var sheet = new Worksheet(20, 5); + var stack = new UndoRedoStack(sheet); + sheet.Cells[0, 0].Value = 1d; + sheet.Cells[0, 0].Format.Bold = true; + + stack.Execute(new AutofillCommand(sheet, RangeRef.Parse("A1:A1"), RangeRef.Parse("A1:A3"), AutofillDirection.Down)); + Assert.True(sheet.Cells[2, 0].FormatOrNull?.Bold); + + stack.Undo(); + + Assert.True(!sheet.Cells.TryGet(2, 0, out var cell) || cell.FormatOrNull is null); + } + + [Fact] + public void UnbatchedUnexecute_RecalculatesDependents() + { + var (sheet, clipboard, _) = Setup(); + sheet.Cells[0, 0].Value = 7d; + sheet.Cells[0, 2].Value = 1d; + sheet.Cells[5, 0].Formula = "=C1*10"; + + var text = CopyRange(sheet, clipboard, "A1:A1"); + var command = new PasteCommand(clipboard, sheet, RangeRef.Parse("C1:C1"), text); + command.Execute(); + Assert.Equal(70d, sheet.Cells[5, 0].Value); + + command.Unexecute(); // direct call, no UndoRedoStack batch + + Assert.Equal(1d, sheet.Cells[0, 2].Value); + Assert.Equal(10d, sheet.Cells[5, 0].Value); + } +} diff --git a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs index c5306b335..9e9a90609 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Cell.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Cell.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace Radzen.Documents.Spreadsheet; @@ -18,8 +19,9 @@ public class Cell private Format? format; /// - /// Gets or sets the format of the cell. + /// Gets or sets the format of the cell. Setting null clears the format. /// + [AllowNull] public Format Format { get @@ -56,7 +58,8 @@ public class Cell { var clone = new Cell(Worksheet, Address) { - Data = new CellData(Value), + // Share Data: it is immutable, and rebuilding from Value re-infers the type (corrupts "0123"). + Data = Data, QuotePrefix = QuotePrefix, Hyperlink = Hyperlink?.Clone(), }; @@ -65,22 +68,24 @@ public class Cell // A clone is a detached snapshot/transport copy and must stay out of the graph. clone.formula = formula; clone.FormulaSyntaxTree = FormulaSyntaxTree; - if (format is not null) - { - clone.format = format.Clone(); - clone.format.Changed += clone.OnFormatChanged; - } + // Not subscribed to: CopyFrom aliases the format into a live cell, and a stale + // subscription would root the discarded clone for the format's lifetime. + clone.format = format?.Clone(); return clone; } /// /// Copies the properties from another cell to this cell. /// - public void CopyFrom(Cell other) + public void CopyFrom(Cell other) => CopyFrom(other, null); + + // The formula override replaces the source formula in the same setter call; adjusting the + // formula on a detached clone instead would register the clone in the live dependency graph. + internal void CopyFrom(Cell other, string? formulaOverride) { ArgumentNullException.ThrowIfNull(other); Data = other.Data; - Formula = other.Formula; + Formula = formulaOverride ?? other.Formula; QuotePrefix = other.QuotePrefix; format?.Changed -= OnFormatChanged; @@ -94,6 +99,17 @@ public class Cell Changed?.Invoke(this); } + // Excel's full clear: contents, format, quote prefix and hyperlink. Clear Contents + // (which keeps formats) belongs to ClearContentsCommand instead. + internal void Clear() + { + Formula = null; + Value = null; + Format = null; + Hyperlink = null; + OnChanged(); + } + internal Format? FormatOrNull => format; /// @@ -191,12 +207,12 @@ public class Cell { if (value is not null && value.StartsWith('\'')) { - if (Formula is not null) - { - Formula = null; - } - Value = value[1..]; + Formula = null; + // Bypass the Value setter: the quote prefix means literal text, so the + // string must not go through type inference ('0123 stays "0123"). + Data = CellData.FromString(value[1..]); QuotePrefix = true; + Worksheet.OnCellValueChanged(this); } else if (value?.StartsWith('=') == true && value != "=") { @@ -204,6 +220,7 @@ public class Cell } else { + Formula = null; Value = value; } } diff --git a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs index a843caae1..56bb4db02 100644 --- a/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs +++ b/Radzen.Blazor/Documents/Spreadsheet/Worksheet.cs @@ -726,37 +726,28 @@ public partial class Worksheet var dr = sr + rowDelta; var dc = sc + colDelta; - if (!sourceSheet.Cells.TryGet(sr, sc, out var srcCell)) - { - continue; - } - if (dr < 0 || dr >= RowCount || dc < 0 || dc >= ColumnCount) { continue; } + if (!sourceSheet.Cells.TryGet(sr, sc, out var srcCell)) + { + // Empty source cells blank the destination, like Excel. + if (Cells.TryGet(dr, dc, out var existing)) + { + existing.Clear(); + cells.Add(existing); + } + continue; + } + + var adjusted = !string.IsNullOrEmpty(srcCell.Formula) && adjustment != FormulaAdjustment.Preserve + ? AdjustFormulaForCopy(srcCell.Formula!, rowDelta, colDelta) + : null; + var dstCell = Cells[dr, dc]; - - if (!string.IsNullOrEmpty(srcCell.Formula)) - { - var formula = srcCell.Formula!; - - if (adjustment == FormulaAdjustment.Preserve) - { - dstCell.Formula = formula; - } - else - { - var adjusted = AdjustFormulaForCopy(formula, rowDelta, colDelta); - dstCell.Formula = adjusted; - } - } - else - { - dstCell.Value = srcCell.Value; - } - + dstCell.CopyFrom(srcCell.Clone(), adjusted); cells.Add(dstCell); } } diff --git a/Radzen.Blazor/Spreadsheet/AutofillCommand.cs b/Radzen.Blazor/Spreadsheet/AutofillCommand.cs index bf93026e5..ce9d5b24e 100644 --- a/Radzen.Blazor/Spreadsheet/AutofillCommand.cs +++ b/Radzen.Blazor/Spreadsheet/AutofillCommand.cs @@ -195,7 +195,7 @@ class AutofillCommand : RangeSnapshotCommandBase { sourceValues.Add(srcCell.Value); sourceFormulas.Add(srcCell.Formula); - sourceFormats.Add(srcCell.Format?.Clone()); + sourceFormats.Add(srcCell.FormatOrNull?.Clone()); } else { @@ -217,10 +217,7 @@ class AutofillCommand : RangeSnapshotCommandBase var dstCell = sheet.Cells[row, column]; - if (sourceFormats[sourceIndex] is not null) - { - dstCell.Format = sourceFormats[sourceIndex]!.Clone(); - } + dstCell.Format = sourceFormats[sourceIndex]?.Clone(); if (!string.IsNullOrEmpty(sourceFormulas[sourceIndex])) { @@ -273,7 +270,7 @@ class AutofillCommand : RangeSnapshotCommandBase { sourceValues.Add(srcCell.Value); sourceFormulas.Add(srcCell.Formula); - sourceFormats.Add(srcCell.Format?.Clone()); + sourceFormats.Add(srcCell.FormatOrNull?.Clone()); } else { @@ -295,10 +292,7 @@ class AutofillCommand : RangeSnapshotCommandBase var dstCell = sheet.Cells[row, column]; - if (sourceFormats[sourceIndex] is not null) - { - dstCell.Format = sourceFormats[sourceIndex]!.Clone(); - } + dstCell.Format = sourceFormats[sourceIndex]?.Clone(); if (!string.IsNullOrEmpty(sourceFormulas[sourceIndex])) { diff --git a/Radzen.Blazor/Spreadsheet/ClearContentsCommand.cs b/Radzen.Blazor/Spreadsheet/ClearContentsCommand.cs index 6c375abcc..ba718ba22 100644 --- a/Radzen.Blazor/Spreadsheet/ClearContentsCommand.cs +++ b/Radzen.Blazor/Spreadsheet/ClearContentsCommand.cs @@ -27,6 +27,7 @@ class ClearContentsCommand : RangeSnapshotCommandBase { Capture(new CellRef(row, column)); + // Deliberately not Cell.Clear: Excel's Clear Contents keeps formats. cell.Formula = null; cell.Value = null; } diff --git a/Radzen.Blazor/Spreadsheet/PasteCommand.cs b/Radzen.Blazor/Spreadsheet/PasteCommand.cs index 0881ba450..2b66565a5 100644 --- a/Radzen.Blazor/Spreadsheet/PasteCommand.cs +++ b/Radzen.Blazor/Spreadsheet/PasteCommand.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using Radzen.Documents.Spreadsheet; namespace Radzen.Blazor.Spreadsheet; @@ -15,7 +13,7 @@ class PasteCommand : RangeSnapshotCommandBase private readonly SpreadsheetClipboard clipboard; private readonly RangeRef destination; private readonly string? text; - private readonly Dictionary result = []; + private readonly Dictionary result = []; private bool pasted; public override SheetAction RequiredAction => SheetAction.EditCell; @@ -32,11 +30,18 @@ class PasteCommand : RangeSnapshotCommandBase { if (pasted) { - // Redo: replay the captured result instead of re-running the (possibly consumed) clipboard. + // Redo: replay the captured result instead of re-running the (possibly consumed) + // clipboard. Execute cleared the snapshot, so re-capture it first or the next + // undo would have nothing to restore. + foreach (var cellRef in result.Keys) + { + Capture(cellRef); + } + Restore(result); return true; } - + var destinationRange = clipboard.GetPasteRange(sheet, destination, text); if (destinationRange == RangeRef.Invalid) @@ -64,25 +69,14 @@ class PasteCommand : RangeSnapshotCommandBase pasted = true; return true; } - + public override void Unexecute() => RestoreSnapshot(); private void CaptureResult() { foreach (var cellRef in snapshot.Keys) { - string? formula = null; - object? value = null; - Format? format = null; - - if (sheet.Cells.TryGet(cellRef.Row, cellRef.Column, out var cell)) - { - value = cell.Value; - formula = cell.Formula; - format = cell.Format?.Clone(); - } - - result[cellRef] = (value, formula, format); + result[cellRef] = sheet.Cells.TryGet(cellRef.Row, cellRef.Column, out var cell) ? cell.Clone() : null; } } -} \ No newline at end of file +} diff --git a/Radzen.Blazor/Spreadsheet/RangeSnapshotCommandBase.cs b/Radzen.Blazor/Spreadsheet/RangeSnapshotCommandBase.cs index d9d9669d8..f2e039069 100644 --- a/Radzen.Blazor/Spreadsheet/RangeSnapshotCommandBase.cs +++ b/Radzen.Blazor/Spreadsheet/RangeSnapshotCommandBase.cs @@ -7,7 +7,7 @@ namespace Radzen.Blazor.Spreadsheet; #nullable enable /// -/// Base class for commands that snapshot a set of cells (value, formula, format) for undo support. +/// Base class for commands that snapshot a set of cells as detached clones for undo support. /// public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand { @@ -23,9 +23,10 @@ public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand protected readonly Worksheet sheet; /// - /// The captured cell snapshots keyed by cell reference. + /// The captured cell snapshots keyed by cell reference. A snapshot is a detached clone, + /// or null when the cell did not exist at capture time. /// - protected readonly Dictionary snapshot = []; + protected readonly Dictionary snapshot = []; /// /// Initializes a new instance of the class. @@ -36,22 +37,11 @@ public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand } /// - /// Captures the current value, formula and format of the cell at into the snapshot. + /// Captures the cell at into the snapshot as a detached clone. /// protected void Capture(CellRef cellRef) { - object? value = null; - string? formula = null; - Format? format = null; - - if (sheet.Cells.TryGet(cellRef.Row, cellRef.Column, out var cell)) - { - value = cell.Value; - formula = cell.Formula; - format = cell.Format?.Clone(); - } - - snapshot[cellRef] = (value, formula, format); + snapshot[cellRef] = sheet.Cells.TryGet(cellRef.Row, cellRef.Column, out var cell) ? cell.Clone() : null; } /// @@ -68,37 +58,38 @@ public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand } /// - /// Restores the captured cells back into the sheet using the formula-or-value convention. + /// Restores the captured cells back into the sheet. /// protected void RestoreSnapshot() => Restore(snapshot); /// - /// Writes the captured (value/formula/format) state of back into the sheet. + /// Writes the captured cell state back into the sheet. Cells that did not exist at capture + /// time are cleared. Batched so dependent formulas recalculate once, even when called + /// outside the undo stack. /// - protected void Restore(IReadOnlyDictionary cells) + protected void Restore(IReadOnlyDictionary cells) { ArgumentNullException.ThrowIfNull(cells); - foreach (var entry in cells) + sheet.Batch(() => { - var cellRef = entry.Key; - var (value, formula, format) = entry.Value; - var cell = sheet.Cells[cellRef.Row, cellRef.Column]; - - if (formula is not null) + foreach (var (cellRef, snap) in cells) { - cell.Formula = formula; + if (snap is null) + { + if (sheet.Cells.TryGet(cellRef.Row, cellRef.Column, out var cell)) + { + cell.Clear(); + } + } + else + { + // Clone again: CopyFrom aliases the format, and the snapshot must stay + // isolated from later edits of the live cell. + sheet.Cells[cellRef.Row, cellRef.Column].CopyFrom(snap.Clone()); + } } - else - { - cell.Value = value; - } - - if (format is not null) - { - cell.Format = format; - } - } + }); } /// diff --git a/Radzen.Blazor/Spreadsheet/SpreadsheetClipboard.cs b/Radzen.Blazor/Spreadsheet/SpreadsheetClipboard.cs index c6e78a448..9a4021afd 100644 --- a/Radzen.Blazor/Spreadsheet/SpreadsheetClipboard.cs +++ b/Radzen.Blazor/Spreadsheet/SpreadsheetClipboard.cs @@ -159,8 +159,7 @@ class SpreadsheetClipboard { if (sourceSheet.Cells.TryGet(sr, sc, out var srcCell)) { - srcCell.Formula = null; - srcCell.Value = null; + srcCell.Clear(); } } }