mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
Transfer the full cell state in spreadsheet paste, cut and autofill
Formats, quote prefixes and hyperlinks are now copied, empty source cells blank their destinations and cut clears the source, matching Excel. Entering a value over a formula cell replaces the formula, quote prefixed entries store literal text and undo restores cells exactly as captured, including after redo. Breaking change: RangeSnapshotCommandBase snapshots cells as detached clones (Dictionary<CellRef, Cell?>) instead of a value/formula/format tuple.
This commit is contained in:
374
Radzen.Blazor.Tests/Spreadsheet/PasteFidelityTests.cs
Normal file
374
Radzen.Blazor.Tests/Spreadsheet/PasteFidelityTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the format of the cell.
|
||||
/// Gets or sets the format of the cell. Setting null clears the format.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the properties from another cell to this cell.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<CellRef, (object? value, string? formula, Format? format)> result = [];
|
||||
private readonly Dictionary<CellRef, Cell?> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Radzen.Blazor.Spreadsheet;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand
|
||||
{
|
||||
@@ -23,9 +23,10 @@ public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand
|
||||
protected readonly Worksheet sheet;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
protected readonly Dictionary<CellRef, (object? value, string? formula, Format? format)> snapshot = [];
|
||||
protected readonly Dictionary<CellRef, Cell?> snapshot = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RangeSnapshotCommandBase"/> class.
|
||||
@@ -36,22 +37,11 @@ public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures the current value, formula and format of the cell at <paramref name="cellRef"/> into the snapshot.
|
||||
/// Captures the cell at <paramref name="cellRef"/> into the snapshot as a detached clone.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,37 +58,38 @@ public abstract class RangeSnapshotCommandBase : ICommand, IProtectedCommand
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the captured cells back into the sheet using the formula-or-value convention.
|
||||
/// Restores the captured cells back into the sheet.
|
||||
/// </summary>
|
||||
protected void RestoreSnapshot() => Restore(snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Writes the captured (value/formula/format) state of <paramref name="cells"/> 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.
|
||||
/// </summary>
|
||||
protected void Restore(IReadOnlyDictionary<CellRef, (object? value, string? formula, Format? format)> cells)
|
||||
protected void Restore(IReadOnlyDictionary<CellRef, Cell?> 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -159,8 +159,7 @@ class SpreadsheetClipboard
|
||||
{
|
||||
if (sourceSheet.Cells.TryGet(sr, sc, out var srcCell))
|
||||
{
|
||||
srcCell.Formula = null;
|
||||
srcCell.Value = null;
|
||||
srcCell.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user