From 9768748bbf386dabad52881f045190a6dc777b46 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sat, 24 May 2025 14:12:35 +1000 Subject: [PATCH 1/8] Add auto-scroll functionality when selecting. --- .../Geometry/Point2d.cs | 8 +- .../Components/Pages/Home.razor | 13 ++- src/BlazorDatasheet/Datasheet.razor | 13 ++- src/BlazorDatasheet/Datasheet.razor.cs | 14 +++ .../Render/AutoScroll/AutoScrollOptions.cs | 14 +++ .../Render/AutoScroll/AutoScroller.razor | 90 +++++++++++++++++++ .../Render/AutoScroll/AutoScroller.razor.js | 78 ++++++++++++++++ 7 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 src/BlazorDatasheet/Render/AutoScroll/AutoScrollOptions.cs create mode 100644 src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor create mode 100644 src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor.js diff --git a/src/BlazorDatasheet.DataStructures/Geometry/Point2d.cs b/src/BlazorDatasheet.DataStructures/Geometry/Point2d.cs index 42be266c..9513aa82 100644 --- a/src/BlazorDatasheet.DataStructures/Geometry/Point2d.cs +++ b/src/BlazorDatasheet.DataStructures/Geometry/Point2d.cs @@ -2,8 +2,8 @@ public struct Point2d { - public double X { get; } - public double Y { get; } + public double X { get; init; } + public double Y { get; init; } public Point2d(double x, double y) { @@ -11,6 +11,10 @@ public Point2d(double x, double y) Y = y; } + public Point2d() + { + } + public override string ToString() { return $"X: {X}, Y: {Y}"; diff --git a/src/BlazorDatasheet.SharedPages/Components/Pages/Home.razor b/src/BlazorDatasheet.SharedPages/Components/Pages/Home.razor index 31ac8e0b..f126f1ff 100644 --- a/src/BlazorDatasheet.SharedPages/Components/Pages/Home.razor +++ b/src/BlazorDatasheet.SharedPages/Components/Pages/Home.razor @@ -26,11 +26,20 @@
+

Options

+
+
+ + +
+
+

Features

@@ -72,9 +81,11 @@ private Sheet _sheet = null!; private Datasheet? _datasheet; + private bool _useAutoScroll; + protected override void OnInitialized() { - _sheet = new Sheet(50, 5); + _sheet = new Sheet(1000, 5); _sheet.Rows.HeadingWidth = 35; _sheet.Commands.PauseHistory(); _sheet.BatchUpdates(); diff --git a/src/BlazorDatasheet/Datasheet.razor b/src/BlazorDatasheet/Datasheet.razor index 54767711..a32ce39f 100644 --- a/src/BlazorDatasheet/Datasheet.razor +++ b/src/BlazorDatasheet/Datasheet.razor @@ -7,6 +7,7 @@ @using BlazorDatasheet.Virtualise @using BlazorDatasheet.Render.Layers @using ColumnHeadingRenderer = BlazorDatasheet.Render.Headings.ColumnHeadingRenderer +@using BlazorDatasheet.Render.AutoScroll @inherits SheetComponentBase
@@ -152,6 +153,14 @@
} + @if (GridLevel == 0) + { + + } + @@ -171,7 +180,8 @@ CellRenderFragment="CellRenderFragment" NumberPrecisionDisplay="NumberPrecisionDisplay" Cache="_visualCellCache"/> - + @if (_showFormulaDependents) { @@ -255,6 +265,7 @@
+ @code{ /// diff --git a/src/BlazorDatasheet/Datasheet.razor.cs b/src/BlazorDatasheet/Datasheet.razor.cs index f919727a..b175fa48 100644 --- a/src/BlazorDatasheet/Datasheet.razor.cs +++ b/src/BlazorDatasheet/Datasheet.razor.cs @@ -15,6 +15,7 @@ using BlazorDatasheet.KeyboardInput; using BlazorDatasheet.Menu; using BlazorDatasheet.Render; +using BlazorDatasheet.Render.AutoScroll; using BlazorDatasheet.Render.Layers; using BlazorDatasheet.Services; using BlazorDatasheet.Virtualise; @@ -179,6 +180,19 @@ public partial class Datasheet : SheetComponentBase, IAsyncDisposable [Parameter] public RenderFragment? MenuItems { get; set; } + /// + /// Provides options to the auto-scroll feature, that scrolls when the user is selecting with a mouse + /// outside the scrollable parent. + /// + [Parameter] + public AutoScrollOptions AutoScrollOptions { get; set; } = new(); + + /// + /// Whether to use the component + /// + [Parameter] + public bool UseAutoScroll { get; set; } + /// /// The datasheet keyboard shortcut manager /// diff --git a/src/BlazorDatasheet/Render/AutoScroll/AutoScrollOptions.cs b/src/BlazorDatasheet/Render/AutoScroll/AutoScrollOptions.cs new file mode 100644 index 00000000..b970b041 --- /dev/null +++ b/src/BlazorDatasheet/Render/AutoScroll/AutoScrollOptions.cs @@ -0,0 +1,14 @@ +namespace BlazorDatasheet.Render.AutoScroll; + +public class AutoScrollOptions +{ + /// + /// The max number of pixels to scroll at each poll interval. + /// + public double MaxVelocity { get; set; } + + /// + /// The polling interval (in ms) that the autoscroller works at. Default is 200 + /// + public int PollIntervalInMs { get; set; } = 200; +} \ No newline at end of file diff --git a/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor b/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor new file mode 100644 index 00000000..88de0f0f --- /dev/null +++ b/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor @@ -0,0 +1,90 @@ +@using BlazorDatasheet.Core.Selecting +@using BlazorDatasheet.DataStructures.Geometry +@using Microsoft.JSInterop +@inject IJSRuntime Js +@implements IAsyncDisposable + +
+ +@code { + + private bool _enabled; + + [Parameter] public bool Enabled { get; set; } = false; + + [Parameter] public required AutoScrollOptions Options { get; set; } = new(); + + [Parameter, EditorRequired] public required Selection? Selection { get; set; } + + private ElementReference _contentEl = default!; + private IJSObjectReference? _autoScroller = null!; + private DotNetObjectReference? _dotnetHelper; + private PeriodicTimer? _pt; + private double _velX; + private double _velY; + + protected override void OnParametersSet() + { + if (_enabled != Enabled) + { + if (_enabled && _pt != null) + { + _pt.Period = TimeSpan.FromMilliseconds(Options.PollIntervalInMs); + } + + _enabled = Enabled; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotnetHelper = DotNetObjectReference.Create(this); + var module = await Js.InvokeAsync("import", "./_content/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor.js"); + _autoScroller = await module.InvokeAsync("createAutoScroller"); + await _autoScroller.InvokeVoidAsync("subscribe", _contentEl, _dotnetHelper); + + _pt = new PeriodicTimer(TimeSpan.FromMilliseconds(Options.PollIntervalInMs)); + while (await _pt.WaitForNextTickAsync()) + { + if (_pt.Period.Milliseconds != Options.PollIntervalInMs) + _pt.Period = TimeSpan.FromMilliseconds(Options.PollIntervalInMs); + + if (_enabled && Selection?.IsSelecting == true) + { + await _autoScroller.InvokeVoidAsync("scrollBy", _velX, _velY); + } + } + } + } + + public async ValueTask DisposeAsync() + { + try + { + if (_autoScroller != null) + { + await _autoScroller.InvokeVoidAsync("dispose"); + await _autoScroller.DisposeAsync(); + } + + _dotnetHelper?.Dispose(); + _pt?.Dispose(); + } + catch (Exception) + { + // ignored + } + } + + [JSInvokable(nameof(HandleMouseOutsideOfScrollableAncestor))] + public Task HandleMouseOutsideOfScrollableAncestor(Point2d point) + { + var p = 1.2; + _velX = Math.Max(Math.Pow(Math.Abs(point.X), p), Options.MaxVelocity) * Math.Sign(point.X); + _velY = Math.Max(Math.Pow(Math.Abs(point.Y), p), Options.MaxVelocity) * Math.Sign(point.Y); + return Task.CompletedTask; + } + +} \ No newline at end of file diff --git a/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor.js b/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor.js new file mode 100644 index 00000000..ed9b8015 --- /dev/null +++ b/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor.js @@ -0,0 +1,78 @@ +export class AutoScroller { + + findScrollableAncestor(element) { + if (!element) + return null + + let parent = element.parentElement + + if (parent == null || element === document.body || element === document.documentElement) + return null + + let overflowY = window.getComputedStyle(parent).overflowY + let overflowX = window.getComputedStyle(parent).overflowX + let overflow = window.getComputedStyle(parent).overflow + + if (overflowY !== 'visible' || overflowX !== 'visible' || overflow !== 'visible') + return parent + + return this.findScrollableAncestor(parent) + } + + scrollBy(x, y) { + if (x === 0 && y === 0) + return + this.ancestor.scrollBy({top: y, left: x, behavior: 'smooth'}) + } + + /** + * + * @param {HTMLElement} el + * @param dotnetHelper + */ + subscribe(el, dotnetHelper) { + this.dotnetHelper = dotnetHelper + this.ancestor = this.findScrollableAncestor(el) ?? document.documentElement + window.addEventListener('mousemove', this.throttle(this.onMouseMove.bind(this), 20)) + } + + /*** + * @param {MouseEvent} e + */ + onMouseMove(e) { + if (!this.dotnetHelper) + return + + let rect = this.ancestor.getBoundingClientRect() + let insideX = rect.left < e.clientX && rect.right > e.clientX + let insideY = rect.top < e.clientY && rect.bottom > e.clientY + let edgeX = e.clientX > rect.right ? rect.right : rect.left + let edgeY = e.clientY > rect.bottom ? rect.bottom : rect.top + + let dx = insideX ? 0 : e.clientX - edgeX + let dy = insideY ? 0 : e.clientY - edgeY + + this.dotnetHelper.invokeMethodAsync("HandleMouseOutsideOfScrollableAncestor", {x: dx, y: dy}) + } + + dispose() { + window.removeEventListener('mousemove', this.onMouseMove) + this.dotnetHelper = null + } + + throttle(mainFunction, delay) { + let timerFlag = null; + return (...args) => { + if (timerFlag === null) { + mainFunction(...args); + timerFlag = setTimeout(() => { + timerFlag = null; + }, delay); + } + }; + } +} + +export function createAutoScroller() { + return new AutoScroller() +} \ No newline at end of file From bb1b2a5155db96a450bced1e28bbfbda199a2e06 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 8 Jun 2025 15:01:16 +1000 Subject: [PATCH 2/8] Make auto-scroll not depend on selection. --- src/BlazorDatasheet/Datasheet.razor | 5 ++--- src/BlazorDatasheet/Datasheet.razor.cs | 6 ++++++ .../Render/AutoScroll/AutoScroller.razor | 20 ++++++++++++------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/BlazorDatasheet/Datasheet.razor b/src/BlazorDatasheet/Datasheet.razor index a32ce39f..86f70695 100644 --- a/src/BlazorDatasheet/Datasheet.razor +++ b/src/BlazorDatasheet/Datasheet.razor @@ -157,8 +157,8 @@ { + IsActive="IsAutoScrollActive" + Enabled="@UseAutoScroll"/> } CellRenderFragment = CreateComponent(); - } \ No newline at end of file diff --git a/src/BlazorDatasheet/Datasheet.razor.cs b/src/BlazorDatasheet/Datasheet.razor.cs index b175fa48..6b2bb482 100644 --- a/src/BlazorDatasheet/Datasheet.razor.cs +++ b/src/BlazorDatasheet/Datasheet.razor.cs @@ -767,6 +767,12 @@ private void HandleCellMouseOver(object? sender, SheetPointerEventArgs args) _selectionManager.HandlePointerOver(args.Row, args.Col); } + private bool IsAutoScrollActive() + { + return _sheet.Selection.IsSelecting; + } + + private async Task AcceptEditAndMoveActiveSelection(Axis axis, int amount) { var acceptEdit = !_sheet.Editor.IsEditing || _sheet.Editor.AcceptEdit(); diff --git a/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor b/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor index 88de0f0f..6de0e37d 100644 --- a/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor +++ b/src/BlazorDatasheet/Render/AutoScroll/AutoScroller.razor @@ -1,5 +1,4 @@ -@using BlazorDatasheet.Core.Selecting -@using BlazorDatasheet.DataStructures.Geometry +@using BlazorDatasheet.DataStructures.Geometry @using Microsoft.JSInterop @inject IJSRuntime Js @implements IAsyncDisposable @@ -10,14 +9,21 @@ private bool _enabled; - [Parameter] public bool Enabled { get; set; } = false; + /// + /// Whether the auto-scroller is allowed to run. + /// + [Parameter] + public bool Enabled { get; set; } [Parameter] public required AutoScrollOptions Options { get; set; } = new(); - [Parameter, EditorRequired] public required Selection? Selection { get; set; } + /// + /// Whether the auto-scroll should scroll. + /// + [Parameter, EditorRequired] public required Func IsActive { get; set; } - private ElementReference _contentEl = default!; - private IJSObjectReference? _autoScroller = null!; + private ElementReference _contentEl; + private IJSObjectReference? _autoScroller; private DotNetObjectReference? _dotnetHelper; private PeriodicTimer? _pt; private double _velX; @@ -51,7 +57,7 @@ if (_pt.Period.Milliseconds != Options.PollIntervalInMs) _pt.Period = TimeSpan.FromMilliseconds(Options.PollIntervalInMs); - if (_enabled && Selection?.IsSelecting == true) + if (_enabled && IsActive()) { await _autoScroller.InvokeVoidAsync("scrollBy", _velX, _velY); } From 9a879cc34e844834b00a525ea7bcf8e995b62b87 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Tue, 28 Oct 2025 11:04:03 +1100 Subject: [PATCH 3/8] Add auto-scroll when selecting ranges in formula. --- src/BlazorDatasheet/Datasheet.razor.cs | 12 ++++++++++- .../TextEditorComponent.razor | 20 +++++++++---------- src/BlazorDatasheet/Edit/EditorLayer.razor | 1 + 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/BlazorDatasheet/Datasheet.razor.cs b/src/BlazorDatasheet/Datasheet.razor.cs index 6b2bb482..c7191442 100644 --- a/src/BlazorDatasheet/Datasheet.razor.cs +++ b/src/BlazorDatasheet/Datasheet.razor.cs @@ -10,6 +10,7 @@ using BlazorDatasheet.Core.Util; using BlazorDatasheet.DataStructures.Geometry; using BlazorDatasheet.Edit; +using BlazorDatasheet.Edit.DefaultComponents; using BlazorDatasheet.Events; using BlazorDatasheet.Extensions; using BlazorDatasheet.KeyboardInput; @@ -769,7 +770,16 @@ private void HandleCellMouseOver(object? sender, SheetPointerEventArgs args) private bool IsAutoScrollActive() { - return _sheet.Selection.IsSelecting; + if (_sheet.Selection.IsSelecting) + return true; + + if (_sheet.Editor.IsEditing && _editorLayer.ActiveEditorContainer?.Instance is TextEditorComponent te) + { + if (te.SelectionInputManager.Selection.IsSelecting) + return true; + } + + return false; } diff --git a/src/BlazorDatasheet/Edit/DefaultComponents/TextEditorComponent.razor b/src/BlazorDatasheet/Edit/DefaultComponents/TextEditorComponent.razor index 6069a56d..c5cc767d 100644 --- a/src/BlazorDatasheet/Edit/DefaultComponents/TextEditorComponent.razor +++ b/src/BlazorDatasheet/Edit/DefaultComponents/TextEditorComponent.razor @@ -61,7 +61,7 @@ private int _currentCaretPosition = 0; private double _highlightInputHeight = 0; private string _currentSnapshot = string.Empty; - private SelectionInputManager _selectionInputManager = null!; + internal SelectionInputManager SelectionInputManager = null!; private bool _canAcceptRanges = true; private List? _functionSuggestions; private FormulaHintBoxResult? _formulaHint; @@ -70,7 +70,7 @@ { _sheet = sheet; _isSoftEdit = _sheet.Editor.IsSoftEdit; - _selectionInputManager = new SelectionInputManager(new Selection(_sheet)); + SelectionInputManager = new SelectionInputManager(new Selection(_sheet)); _formulaOptions = sheet.FormulaEngine.Options; StateHasChanged(); } @@ -113,7 +113,7 @@ _canAcceptRanges = false; } - _selectionInputManager.Clear(); + SelectionInputManager.Clear(); } private void SuggestFunctions() @@ -161,7 +161,7 @@ private void SetEditValueToSelectionPreview() { - var selection = _selectionInputManager.Selection; + var selection = SelectionInputManager.Selection; var sb = new StringBuilder(); for (var i = 0; i < selection.Regions.Count; i++) { @@ -193,13 +193,13 @@ { if (_canAcceptRanges) { - if (_selectionInputManager.Selection.IsEmpty()) + if (SelectionInputManager.Selection.IsEmpty()) { - _selectionInputManager.Selection.Set(_sheet.Editor.EditCell!.Row, _sheet.Editor.EditCell!.Col); + SelectionInputManager.Selection.Set(_sheet.Editor.EditCell!.Row, _sheet.Editor.EditCell!.Col); } var offset = KeyUtil.GetMovementFromArrowKey(key); - _selectionInputManager.HandleArrowKeyDown(shiftKey, offset); + SelectionInputManager.HandleArrowKeyDown(shiftKey, offset); SetEditValueToSelectionPreview(); return true; } @@ -221,7 +221,7 @@ { if (_canAcceptRanges) { - _selectionInputManager.HandlePointerDown(row, col, shiftKey, ctrlKey, metaKey, 1); + SelectionInputManager.HandlePointerDown(row, col, shiftKey, ctrlKey, metaKey, 1); SetEditValueToSelectionPreview(); return true; } @@ -233,7 +233,7 @@ { if (_canAcceptRanges) { - _selectionInputManager.HandlePointerOver(row, col); + SelectionInputManager.HandlePointerOver(row, col); SetEditValueToSelectionPreview(); return true; } @@ -243,7 +243,7 @@ public override async Task HandleWindowMouseUpAsync() { - _selectionInputManager.HandleWindowMouseUp(); + SelectionInputManager.HandleWindowMouseUp(); if (_canAcceptRanges) { diff --git a/src/BlazorDatasheet/Edit/EditorLayer.razor b/src/BlazorDatasheet/Edit/EditorLayer.razor index a15838d9..c9990ba5 100644 --- a/src/BlazorDatasheet/Edit/EditorLayer.razor +++ b/src/BlazorDatasheet/Edit/EditorLayer.razor @@ -198,6 +198,7 @@ { _activeCellEditor.RequestCancelEdit -= HandleEditorRequestCancelEdit; _activeCellEditor.RequestAcceptEdit -= HandleEditorRequestAcceptEdit; + _activeCellEditor = null; } IsEditing = false; From a7a31edf8cc74357cd17bc0462a3a376af76698c Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Tue, 28 Oct 2025 14:41:47 +1100 Subject: [PATCH 4/8] Fix auto-scroll and formula range selection for frozen rows/column sections. --- .../Formatting/CellFormatSimpleExample.razor | 8 +- src/BlazorDatasheet/Datasheet.razor | 4 + src/BlazorDatasheet/Datasheet.razor.cs | 81 ++++++++++++++++--- src/BlazorDatasheet/Edit/EditorLayer.razor | 8 +- 4 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/BlazorDatasheet.SharedPages/Components/Examples/Formatting/CellFormatSimpleExample.razor b/src/BlazorDatasheet.SharedPages/Components/Examples/Formatting/CellFormatSimpleExample.razor index 7b1c4955..cd38e3f9 100644 --- a/src/BlazorDatasheet.SharedPages/Components/Examples/Formatting/CellFormatSimpleExample.razor +++ b/src/BlazorDatasheet.SharedPages/Components/Examples/Formatting/CellFormatSimpleExample.razor @@ -6,8 +6,12 @@ -
- +
+
@code { diff --git a/src/BlazorDatasheet/Datasheet.razor b/src/BlazorDatasheet/Datasheet.razor index 86f70695..740855af 100644 --- a/src/BlazorDatasheet/Datasheet.razor +++ b/src/BlazorDatasheet/Datasheet.razor @@ -81,6 +81,7 @@ ShowRowHeadings="@ShowRowHeadings" ShowFormulaDependents="@ShowFormulaDependents" StickyHeaders="StickyHeaders" + @ref="_frozenTop" FrozenLeftCount="_frozenLeftCount" FrozenRightCount="_frozenRightCount" NumberPrecisionDisplay="NumberPrecisionDisplay" @@ -142,6 +143,7 @@ ShowColHeadings="false" ShowRowHeadings="false" ShowFormulaDependents="@ShowFormulaDependents" + @ref="_frozenLeft" FrozenLeftCount="0" FrozenRightCount="0" NumberPrecisionDisplay="NumberPrecisionDisplay" @@ -225,6 +227,7 @@ ShowColHeadings="false" ShowFormulaDependents="@ShowFormulaDependents" ShowRowHeadings="false" + @ref="_frozenRight" FrozenRightCount="0" FrozenLeftCount="0" NumberPrecisionDisplay="NumberPrecisionDisplay" @@ -249,6 +252,7 @@ ShowFormulaDependents="@ShowFormulaDependents" ShowRowHeadings="@ShowRowHeadings" StickyHeaders="StickyHeaders" + @ref="_frozenBottom" FrozenLeftCount="_frozenLeftCount" FrozenRightCount="_frozenRightCount" NumberPrecisionDisplay="NumberPrecisionDisplay" diff --git a/src/BlazorDatasheet/Datasheet.razor.cs b/src/BlazorDatasheet/Datasheet.razor.cs index c7191442..8ba1cc3c 100644 --- a/src/BlazorDatasheet/Datasheet.razor.cs +++ b/src/BlazorDatasheet/Datasheet.razor.cs @@ -189,7 +189,7 @@ public partial class Datasheet : SheetComponentBase, IAsyncDisposable public AutoScrollOptions AutoScrollOptions { get; set; } = new(); /// - /// Whether to use the component + /// Whether to use the component, which scrolls when the user is selecting with a mouse. /// [Parameter] public bool UseAutoScroll { get; set; } @@ -272,6 +272,11 @@ public partial class Datasheet : SheetComponentBase, IAsyncDisposable private Viewport _currentViewport = new(new(-1, -1), new(0, 0, 0, 0)); + private Datasheet? _frozenLeft; + private Datasheet? _frozenRight; + private Datasheet? _frozenTop; + private Datasheet? _frozenBottom; + /// /// Width of the sheet, including any gutters (row headings etc.) /// @@ -608,9 +613,16 @@ private void RegisterDefaultShortcuts() private void HandleCellMouseDown(object? sender, SheetPointerEventArgs args) { - if (_sheet.Editor.IsEditing && - _editorLayer.HandleMouseDown(args.Row, args.Col, args.CtrlKey, args.ShiftKey, args.AltKey, args.MetaKey)) - return; + if (_sheet.Editor.IsEditing) + { + var activeEditor = GetActiveEditorLayer(); + if (activeEditor != null && activeEditor.HandleMouseDown(args.Row, args.Col, args.CtrlKey, args.ShiftKey, + args.AltKey, + args.MetaKey)) + { + return; + } + } // if rmc and inside a selection, don't do anything if (args.MouseButton == 2 && _sheet.Selection.Contains(args.Row, args.Col)) @@ -643,8 +655,8 @@ private async Task HandleWindowKeyDown(KeyboardEventArgs e) if (MenuService.IsMenuOpen()) return false; - var editorHandled = _editorLayer.HandleKeyDown(e.Key, e.CtrlKey, e.ShiftKey, e.AltKey, e.MetaKey); - if (editorHandled) + var editorHandled = GetActiveEditorLayer()?.HandleKeyDown(e.Key, e.CtrlKey, e.ShiftKey, e.AltKey, e.MetaKey); + if (editorHandled == true) return true; var modifiers = e.GetModifiers(); @@ -686,8 +698,12 @@ private async Task HandleWindowMouseDown(MouseEventArgs e) private async Task HandleWindowMouseUp(MouseEventArgs arg) { - if (await _editorLayer.HandleWindowMouseUpAsync()) - return true; + if (_sheet.Editor.IsEditing) + { + var activeEditor = GetActiveEditorLayer(); + if (activeEditor != null && await activeEditor.HandleWindowMouseUpAsync()) + return true; + } _selectionManager.HandleWindowMouseUp(); return false; @@ -761,9 +777,16 @@ private async Task BeginEdit(int row, int col, EditEntryMode mode, string entryC private void HandleCellMouseOver(object? sender, SheetPointerEventArgs args) { - if (_sheet.Editor.IsEditing && - _editorLayer.HandleMouseOver(args.Row, args.Col, args.CtrlKey, args.ShiftKey, args.AltKey, args.MetaKey)) - return; + if (_sheet.Editor.IsEditing) + { + var activeEditor = GetActiveEditorLayer(); + if (activeEditor != null) + { + if (activeEditor.HandleMouseOver(args.Row, args.Col, args.CtrlKey, args.ShiftKey, args.AltKey, + args.MetaKey)) + return; + } + } _selectionManager.HandlePointerOver(args.Row, args.Col); } @@ -773,7 +796,7 @@ private bool IsAutoScrollActive() if (_sheet.Selection.IsSelecting) return true; - if (_sheet.Editor.IsEditing && _editorLayer.ActiveEditorContainer?.Instance is TextEditorComponent te) + if (_sheet.Editor.IsEditing && GetActiveEditorLayer()?.ActiveEditorContainer?.Instance is TextEditorComponent te) { if (te.SelectionInputManager.Selection.IsSelecting) return true; @@ -948,6 +971,40 @@ protected override bool ShouldRender() return shouldRender; } + private bool Contains(int row, int col) + { + return col <= _viewRegion.Right - _frozenRightCount && + col >= _viewRegion.Left + _frozenLeftCount && + row <= _viewRegion.Bottom - _frozenBottomCount && + row >= _viewRegion.Top + _frozenTopCount; + } + + internal EditorLayer? GetActiveEditorLayer() + { + if (!_sheet.Editor.IsEditing) + return null; + + var editRow = _sheet.Editor.EditCell!.Row; + var editCol = _sheet.Editor.EditCell!.Col; + + if (Contains(editRow, editCol)) + return _editorLayer; + + if (_frozenLeft?.Contains(editRow, editCol) == true) + return _frozenLeft._editorLayer; + + if (_frozenRight?.Contains(editRow, editCol) == true) + return _frozenRight._editorLayer; + + if (_frozenTop?.Contains(editRow, editCol) == true) + return _frozenTop._editorLayer; + + if (_frozenBottom?.Contains(editRow, editCol) == true) + return _frozenBottom._editorLayer; + + return null; + } + public async ValueTask DisposeAsync() { if (_dotnetHelper is not null) diff --git a/src/BlazorDatasheet/Edit/EditorLayer.razor b/src/BlazorDatasheet/Edit/EditorLayer.razor index c9990ba5..4de437d0 100644 --- a/src/BlazorDatasheet/Edit/EditorLayer.razor +++ b/src/BlazorDatasheet/Edit/EditorLayer.razor @@ -91,7 +91,7 @@ switch (cellType) { case "default": - return typeof(TextEditorComponent); + break; case "boolean": return typeof(BoolEditorComponent); case "select": @@ -199,10 +199,10 @@ _activeCellEditor.RequestCancelEdit -= HandleEditorRequestCancelEdit; _activeCellEditor.RequestAcceptEdit -= HandleEditorRequestAcceptEdit; _activeCellEditor = null; + + IsEditing = false; + StateHasChanged(); } - - IsEditing = false; - StateHasChanged(); } private string GetEditorDisplayStyling() From 8c3acf836a8371e51391a7e7305d0aaf5ba19553 Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Tue, 28 Oct 2025 20:28:20 +1100 Subject: [PATCH 5/8] Scroll sheet when shift + arrow key pressed --- src/BlazorDatasheet/Datasheet.razor.cs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/BlazorDatasheet/Datasheet.razor.cs b/src/BlazorDatasheet/Datasheet.razor.cs index 8ba1cc3c..d2177887 100644 --- a/src/BlazorDatasheet/Datasheet.razor.cs +++ b/src/BlazorDatasheet/Datasheet.razor.cs @@ -712,6 +712,8 @@ private async Task HandleWindowMouseUp(MouseEventArgs arg) private async Task HandleArrowKeysDown(bool shift, Offset offset) { var accepted = true; + var oldActiveRegion = _sheet.Selection.ActiveRegion?.Clone(); + if (_sheet.Editor.IsEditing) accepted = _sheet.Editor.IsSoftEdit && _sheet.Editor.AcceptEdit(); @@ -722,6 +724,20 @@ private async Task HandleArrowKeysDown(bool shift, Offset offset) if (!shift && IsDataSheetActive) await ScrollToActiveCellPosition(); + var activeRegion = _sheet.Selection.ActiveRegion; + + if (shift && IsDataSheetActive && oldActiveRegion != null && activeRegion != null) + { + var newRegions = activeRegion.Area > oldActiveRegion.Area + ? activeRegion.Break(oldActiveRegion) + : oldActiveRegion.Break(activeRegion); + + if (newRegions.Count == 1) + { + await ScrollToContainRegion(newRegions.First()); + } + } + return true; } @@ -796,7 +812,8 @@ private bool IsAutoScrollActive() if (_sheet.Selection.IsSelecting) return true; - if (_sheet.Editor.IsEditing && GetActiveEditorLayer()?.ActiveEditorContainer?.Instance is TextEditorComponent te) + if (_sheet.Editor.IsEditing && + GetActiveEditorLayer()?.ActiveEditorContainer?.Instance is TextEditorComponent te) { if (te.SelectionInputManager.Selection.IsSelecting) return true; From cb8375eca224b43d1a7afda9172a616597ebc2be Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Thu, 30 Oct 2025 21:12:17 +1100 Subject: [PATCH 6/8] Add event to active selection changed. --- .../Selection/ActiveRegionChangedEvent.cs | 15 ++++ .../Selecting/Selection.cs | 85 +++++++++++++------ .../Selecting/SelectionSnapshot.cs | 6 +- .../Geometry/IRegion.cs | 6 -- .../Geometry/Region.cs | 2 +- .../Render/SelectionInputManager.cs | 2 +- .../SheetTests/SelectionTests.cs | 38 ++++++++- 7 files changed, 117 insertions(+), 37 deletions(-) create mode 100644 src/BlazorDatasheet.Core/Events/Selection/ActiveRegionChangedEvent.cs diff --git a/src/BlazorDatasheet.Core/Events/Selection/ActiveRegionChangedEvent.cs b/src/BlazorDatasheet.Core/Events/Selection/ActiveRegionChangedEvent.cs new file mode 100644 index 00000000..539555b2 --- /dev/null +++ b/src/BlazorDatasheet.Core/Events/Selection/ActiveRegionChangedEvent.cs @@ -0,0 +1,15 @@ +using BlazorDatasheet.DataStructures.Geometry; + +namespace BlazorDatasheet.Core.Events.Selection; + +public class ActiveRegionChangedEvent +{ + public IRegion? OldRegion { get; } + public IRegion? NewRegion { get; } + + public ActiveRegionChangedEvent(IRegion? oldRegion, IRegion? newRegion) + { + OldRegion = oldRegion; + NewRegion = newRegion; + } +} \ No newline at end of file diff --git a/src/BlazorDatasheet.Core/Selecting/Selection.cs b/src/BlazorDatasheet.Core/Selecting/Selection.cs index 5a2bf684..6162f24e 100644 --- a/src/BlazorDatasheet.Core/Selecting/Selection.cs +++ b/src/BlazorDatasheet.Core/Selecting/Selection.cs @@ -12,7 +12,9 @@ public class Selection /// /// The region that is active for accepting user input, usually the most recent region added /// - public IRegion? ActiveRegion { get; private set; } + public IRegion? ActiveRegion => _activeRegionIndex >= 0 ? _regions[_activeRegionIndex] : null; + + private int _activeRegionIndex = -1; private readonly List _regions = new(); @@ -71,6 +73,11 @@ public class Selection ///
public EventHandler? ActiveCellPositionChanged; + /// + /// Fired when the active region changes + /// + public EventHandler? ActiveRegionChanged; + public Selection(Sheet sheet) { _sheet = sheet; @@ -167,8 +174,8 @@ private void EmitSelectingChanged() public void ClearSelections() { var oldRegions = CloneRegions(); + SetActiveRegionIndex(-1); _regions.Clear(); - ActiveRegion = null; EmitSelectionChange(oldRegions); } @@ -196,10 +203,31 @@ private void Add(List regions) _regions.Add(expandedRegion); } - ActiveRegion = _regions.LastOrDefault(); + SetActiveRegionIndex(_regions.Count - 1); EmitSelectionChange(oldRegions); } + /// + /// Sets the active region out with the updated region + /// + /// + private void SwapActiveRegion(IRegion newRegion) + { + var oldRegion = ActiveRegion; + + if (_activeRegionIndex >= 0 && _activeRegionIndex < _regions.Count) + _regions[_activeRegionIndex] = newRegion; + + ActiveRegionChanged?.Invoke(this, new ActiveRegionChangedEvent(oldRegion, ActiveRegion)); + } + + private void SetActiveRegionIndex(int index) + { + var oldRegion = ActiveRegion; + _activeRegionIndex = index; + ActiveRegionChanged?.Invoke(this, new ActiveRegionChangedEvent(oldRegion, ActiveRegion)); + } + /// /// Extends the active position to the row/col specified /// @@ -224,7 +252,8 @@ public void ExtendTo(int row, int col) var expanded = _sheet.ExpandRegionOverMerges(newRegion); if (expanded != null) - ActiveRegion.Set(expanded); + SwapActiveRegion(expanded); + EmitSelectionChange(oldRegions); } @@ -252,7 +281,7 @@ public void ExpandEdge(Edge edge, int amount) var expanded = _sheet.ExpandRegionOverMerges(newRegion); expanded = expanded?.GetIntersection(_sheet.Region); if (expanded != null) - ActiveRegion.Set(expanded); + SwapActiveRegion(expanded); EmitSelectionChange(oldRegions); } @@ -292,7 +321,7 @@ public void ContractEdge(Edge edge, int amount) if (contracted != null && (contracted.Contains(activeCellRegion) || contracted is RowRegion or ColumnRegion)) - ActiveRegion.Set(contracted); + SwapActiveRegion(contracted); else { ExpandEdge(edge.GetOpposite(), amount); @@ -320,6 +349,8 @@ public void Set(int row, int col) public void Set(List regions) { _regions.Clear(); + _activeRegionIndex = -1; + if (_sheet.Area == 0 || regions.Count == 0) return; @@ -343,7 +374,7 @@ public void ConstrainSelectionToSheet() constrainedRegions.Add(intersection); } - ActiveRegion = null; + SetActiveRegionIndex(-1); _regions.Clear(); Set(constrainedRegions); } @@ -489,14 +520,16 @@ public void MoveActivePositionByRow(int rowDir) newRow = activeRegionFixed.Top; if (newCol > activeRegionFixed.Right) { - var newActiveRegion = GetRegionAfterActive(); + var newActiveRegionIndex = GetRegionIndexAfterActive(); + var newActiveRegion = _regions[newActiveRegionIndex]; var newActiveRegionFixed = newActiveRegion.GetIntersection(_sheet.Region); if (newActiveRegionFixed == null) return; newCol = newActiveRegionFixed.Left; newRow = newActiveRegionFixed.Top; - ActiveRegion = newActiveRegion; + + SetActiveRegionIndex(newActiveRegionIndex); } } else if (newRow < activeRegionFixed.Top) @@ -505,13 +538,14 @@ public void MoveActivePositionByRow(int rowDir) newRow = activeRegionFixed.Bottom; if (newCol < activeRegionFixed.Left) { - var newActiveRegion = GetRegionAfterActive(); + var newActiveRegionIndex = GetRegionIndexAfterActive(); + var newActiveRegion = _regions[newActiveRegionIndex]; var newActiveRegionFixed = newActiveRegion.GetIntersection(_sheet.Region); if (newActiveRegionFixed == null) return; newCol = newActiveRegionFixed.Right; newRow = newActiveRegionFixed.Bottom; - ActiveRegion = newActiveRegion; + SetActiveRegionIndex(newActiveRegionIndex); } } @@ -585,13 +619,14 @@ public void MoveActivePositionByCol(int colDir) newRow++; if (newRow > activeRegionFixed.Bottom) { - var newActiveRegion = GetRegionAfterActive(); + var newActiveRegionIndex = GetRegionIndexAfterActive(); + var newActiveRegion = _regions[newActiveRegionIndex]; var newActiveRegionFixed = newActiveRegion.GetIntersection(_sheet.Region); if (newActiveRegionFixed == null) return; newCol = newActiveRegionFixed.Left; newRow = newActiveRegionFixed.Top; - ActiveRegion = newActiveRegion; + SetActiveRegionIndex(newActiveRegionIndex); } } else if (newCol < activeRegionFixed.Left) @@ -600,13 +635,14 @@ public void MoveActivePositionByCol(int colDir) newRow--; if (newRow < activeRegionFixed.Top) { - var newActiveRegion = GetRegionAfterActive(); + var newActiveRegionIndex = GetRegionIndexAfterActive(); + var newActiveRegion = _regions[newActiveRegionIndex]; var newActiveRegionFixed = newActiveRegion.GetIntersection(_sheet.Region); if (newActiveRegionFixed == null) return; newCol = newActiveRegionFixed.Right; newRow = newActiveRegionFixed.Bottom; - ActiveRegion = newActiveRegion; + SetActiveRegionIndex(newActiveRegionIndex); } } @@ -614,15 +650,15 @@ public void MoveActivePositionByCol(int colDir) EmitSelectionChange(oldRegions); } - private IRegion GetRegionAfterActive() + private int GetRegionIndexAfterActive() { - var activeRegionIndex = _regions.IndexOf(ActiveRegion!); - if (activeRegionIndex == -1) + var index = _activeRegionIndex; + if (index == -1) throw new Exception("No range is active?"); - activeRegionIndex++; - if (activeRegionIndex >= _regions.Count) - activeRegionIndex = 0; - return _regions[activeRegionIndex]; + index++; + if (index >= _regions.Count) + index = 0; + return index; } private IRegion GetRegionBeforeActive() @@ -698,14 +734,13 @@ internal SelectionSnapshot GetSelectionSnapshot() { var activeRegionIndex = ActiveRegion != null ? _regions.IndexOf(ActiveRegion!) : -1; var regions = _regions.Select(x => x.Clone()).ToList(); - var activeRegionClone = activeRegionIndex != -1 ? regions[activeRegionIndex] : null; - return new SelectionSnapshot(activeRegionClone, regions, ActiveCellPosition); + return new SelectionSnapshot(_activeRegionIndex, regions, ActiveCellPosition); } internal void Restore(SelectionSnapshot selectionSnapshot) { var oldRegions = CloneRegions(); - this.ActiveRegion = selectionSnapshot.ActiveRegion; + SetActiveRegionIndex(selectionSnapshot.ActiveRegionIndex); _regions.Clear(); _regions.AddRange(selectionSnapshot.Regions); ActiveCellPosition = selectionSnapshot.ActiveCellPosition; diff --git a/src/BlazorDatasheet.Core/Selecting/SelectionSnapshot.cs b/src/BlazorDatasheet.Core/Selecting/SelectionSnapshot.cs index 5e1a76ab..d1d2c0c3 100644 --- a/src/BlazorDatasheet.Core/Selecting/SelectionSnapshot.cs +++ b/src/BlazorDatasheet.Core/Selecting/SelectionSnapshot.cs @@ -4,13 +4,13 @@ namespace BlazorDatasheet.Core.Selecting; internal class SelectionSnapshot { - public IRegion? ActiveRegion { get; } + public int ActiveRegionIndex { get; } public IReadOnlyList Regions { get; } public CellPosition ActiveCellPosition { get; private set; } - public SelectionSnapshot(IRegion? activeRegion, IReadOnlyList regions, CellPosition activeCellPosition) + public SelectionSnapshot(int activeRegionIndex, IReadOnlyList regions, CellPosition activeCellPosition) { - ActiveRegion = activeRegion; + ActiveRegionIndex = activeRegionIndex; Regions = regions; ActiveCellPosition = activeCellPosition; } diff --git a/src/BlazorDatasheet.DataStructures/Geometry/IRegion.cs b/src/BlazorDatasheet.DataStructures/Geometry/IRegion.cs index 589f3923..d3e90511 100644 --- a/src/BlazorDatasheet.DataStructures/Geometry/IRegion.cs +++ b/src/BlazorDatasheet.DataStructures/Geometry/IRegion.cs @@ -250,10 +250,4 @@ public interface IRegion : IEquatable /// Shift the entire region by the amount specified /// void Shift(int dRowStart, int dRowEnd, int dColStart, int dColEnd); - - /// - /// Sets the region's start and end to those of - /// - /// - void Set(IRegion region); } \ No newline at end of file diff --git a/src/BlazorDatasheet.DataStructures/Geometry/Region.cs b/src/BlazorDatasheet.DataStructures/Geometry/Region.cs index 2b4eb8fb..c177e600 100644 --- a/src/BlazorDatasheet.DataStructures/Geometry/Region.cs +++ b/src/BlazorDatasheet.DataStructures/Geometry/Region.cs @@ -88,7 +88,7 @@ protected void SetOrderedBounds() /// Sets the region's start and end to those of /// /// - public void Set(IRegion region) + private void Set(IRegion region) { Start = region.Start; End = region.End; diff --git a/src/BlazorDatasheet/Render/SelectionInputManager.cs b/src/BlazorDatasheet/Render/SelectionInputManager.cs index d0d2254a..21609967 100644 --- a/src/BlazorDatasheet/Render/SelectionInputManager.cs +++ b/src/BlazorDatasheet/Render/SelectionInputManager.cs @@ -3,7 +3,7 @@ namespace BlazorDatasheet.Render; -public class SelectionInputManager +internal class SelectionInputManager { private readonly Selection _selection; public Selection Selection => _selection; diff --git a/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs b/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs index b0cf2400..64e2915f 100644 --- a/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs +++ b/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using BlazorDatasheet.Core.Data; using BlazorDatasheet.Core.Selecting; @@ -252,4 +253,39 @@ public void Active_Cell_Should_Move_To_Left_With_Col_Selection_And_Enter() manager.Selection.ActiveCellPosition.col.Should().Be(0); } + + [Test] + public void Setting_Active_Region_In_Various_Ways_Fires_Active_Region_Event() + { + IRegion? oldRegion = null; + IRegion? newRegion = null; + + _sheet.Selection.ActiveRegionChanged += (sender, ev) => + { + oldRegion = ev.OldRegion; + newRegion = ev.NewRegion; + }; + + var initialRegion = new Region(2, 2); + + + var fns = new List() + { + () => _sheet.Selection.ExpandEdge(Edge.Bottom, 1), + () => _sheet.Selection.ContractEdge(Edge.Bottom, 1), + () => + { + _sheet.Selection.BeginSelectingCell(10, 10); + _sheet.Selection.EndSelecting(); + } + }; + + foreach (var fn in fns) + { + _sheet.Selection.Set(2, 2); + fn.Invoke(); + newRegion.Should().BeEquivalentTo(_sheet.Selection.ActiveRegion); + oldRegion.Should().BeEquivalentTo(initialRegion); + } + } } \ No newline at end of file From ae214ec389f778f1b0e1c8e73040a9626c9cd7ad Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Sun, 2 Nov 2025 13:46:45 +1100 Subject: [PATCH 7/8] Improve selection logic --- .../Selecting/Selection.cs | 29 +++-- .../Geometry/Rect.cs | 1 - src/BlazorDatasheet/Datasheet.razor.cs | 104 +++++++++++++----- .../Render/SelectionInputManager.cs | 2 +- 4 files changed, 94 insertions(+), 42 deletions(-) diff --git a/src/BlazorDatasheet.Core/Selecting/Selection.cs b/src/BlazorDatasheet.Core/Selecting/Selection.cs index 6162f24e..ed64d208 100644 --- a/src/BlazorDatasheet.Core/Selecting/Selection.cs +++ b/src/BlazorDatasheet.Core/Selecting/Selection.cs @@ -44,7 +44,7 @@ public class Selection /// /// The position that the selecting process was started at /// - public CellPosition SelectingStartPosition { get; private set; } + private CellPosition _selectingStartPosition; public bool IsSelecting => SelectingRegion != null; @@ -91,7 +91,7 @@ public void BeginSelectingCell(int row, int col) return; this.SelectingRegion = new Region(row, col); - this.SelectingStartPosition = new CellPosition(row, col); + this._selectingStartPosition = new CellPosition(row, col); this._selectingMode = SelectionMode.Cell; this.SelectingRegion = _sheet.ExpandRegionOverMerges(SelectingRegion); EmitSelectingChanged(); @@ -102,7 +102,7 @@ public void BeginSelectingCol(int col) if (_sheet.Area == 0) return; this.SelectingRegion = new ColumnRegion(col, col); - this.SelectingStartPosition = new CellPosition(0, col); + this._selectingStartPosition = new CellPosition(0, col); this._selectingMode = SelectionMode.Column; this.SelectingRegion = _sheet.ExpandRegionOverMerges(SelectingRegion); EmitSelectingChanged(); @@ -113,7 +113,7 @@ public void BeginSelectingRow(int row) if (_sheet.Area == 0) return; this.SelectingRegion = new RowRegion(row, row); - this.SelectingStartPosition = new CellPosition(row, 0); + this._selectingStartPosition = new CellPosition(row, 0); this._selectingMode = SelectionMode.Row; this.SelectingRegion = _sheet.ExpandRegionOverMerges(SelectingRegion); EmitSelectingChanged(); @@ -127,13 +127,13 @@ public void UpdateSelectingEndPosition(int row, int col) switch (_selectingMode) { case SelectionMode.Column: - SelectingRegion = new ColumnRegion(SelectingStartPosition.col, col); + SelectingRegion = new ColumnRegion(_selectingStartPosition.col, col); break; case SelectionMode.Row: - SelectingRegion = new RowRegion(SelectingStartPosition.row, row); + SelectingRegion = new RowRegion(_selectingStartPosition.row, row); break; case SelectionMode.Cell: - SelectingRegion = new Region(SelectingStartPosition.row, row, SelectingStartPosition.col, col); + SelectingRegion = new Region(_selectingStartPosition.row, row, _selectingStartPosition.col, col); break; } @@ -151,7 +151,7 @@ public void EndSelecting() { if (SelectingRegion == null) return; - SetActiveCellPosition(SelectingStartPosition.row, SelectingStartPosition.col); + SetActiveCellPosition(_selectingStartPosition.row, _selectingStartPosition.col); var oldRegions = Regions.Select(x => x.Clone()).ToList(); this.Add(SelectingRegion); SelectingRegion = null; @@ -213,6 +213,9 @@ private void Add(List regions) /// private void SwapActiveRegion(IRegion newRegion) { + if (newRegion == ActiveRegion) + return; + var oldRegion = ActiveRegion; if (_activeRegionIndex >= 0 && _activeRegionIndex < _regions.Count) @@ -223,6 +226,9 @@ private void SwapActiveRegion(IRegion newRegion) private void SetActiveRegionIndex(int index) { + if (index == _activeRegionIndex) + return; + var oldRegion = ActiveRegion; _activeRegionIndex = index; ActiveRegionChanged?.Invoke(this, new ActiveRegionChangedEvent(oldRegion, ActiveRegion)); @@ -463,9 +469,10 @@ public void GrowActiveSelection(Offset offset) /// public void MoveActivePositionByRow(int rowDir) { - var oldRegions = CloneRegions(); if (ActiveRegion == null || rowDir == 0) return; + + var oldRegions = CloneRegions(); rowDir = Math.Sign(rowDir); @@ -561,9 +568,10 @@ public void MoveActivePositionByRow(int rowDir) /// public void MoveActivePositionByCol(int colDir) { - var oldRegions = CloneRegions(); if (ActiveRegion == null || colDir == 0) return; + + var oldRegions = CloneRegions(); colDir = Math.Sign(colDir); @@ -732,7 +740,6 @@ internal void SetActiveCellPosition(int row, int col) internal SelectionSnapshot GetSelectionSnapshot() { - var activeRegionIndex = ActiveRegion != null ? _regions.IndexOf(ActiveRegion!) : -1; var regions = _regions.Select(x => x.Clone()).ToList(); return new SelectionSnapshot(_activeRegionIndex, regions, ActiveCellPosition); } diff --git a/src/BlazorDatasheet.DataStructures/Geometry/Rect.cs b/src/BlazorDatasheet.DataStructures/Geometry/Rect.cs index 861442c2..11aaef18 100644 --- a/src/BlazorDatasheet.DataStructures/Geometry/Rect.cs +++ b/src/BlazorDatasheet.DataStructures/Geometry/Rect.cs @@ -47,7 +47,6 @@ public Rect(double x, double y, double width, double height) return intersection; } - public override string ToString() { return $"X: {X}, Y: {Y}, Width: {Width}, Height: {Height}"; diff --git a/src/BlazorDatasheet/Datasheet.razor.cs b/src/BlazorDatasheet/Datasheet.razor.cs index d2177887..e1826022 100644 --- a/src/BlazorDatasheet/Datasheet.razor.cs +++ b/src/BlazorDatasheet/Datasheet.razor.cs @@ -5,6 +5,7 @@ using BlazorDatasheet.Core.Edit; using BlazorDatasheet.Core.Events.Edit; using BlazorDatasheet.Core.Events.Layout; +using BlazorDatasheet.Core.Events.Selection; using BlazorDatasheet.Core.Events.Visual; using BlazorDatasheet.Core.Interfaces; using BlazorDatasheet.Core.Util; @@ -429,6 +430,7 @@ private void RemoveEvents(Sheet sheet) if (GridLevel == 0) { + sheet.Selection.ActiveRegionChanged -= ActiveRegionChanged; sheet.Rows.Inserted -= HandleRowColInserted; sheet.Columns.Inserted -= HandleRowColInserted; sheet.Rows.Removed -= HandleRowColRemoved; @@ -445,8 +447,10 @@ private void AddEvents(Sheet sheet) sheet.Editor.EditFinished += EditorOnEditFinished; sheet.SheetDirty += SheetOnSheetDirty; sheet.ScreenUpdatingChanged += ScreenUpdatingChanged; + if (GridLevel == 0) { + sheet.Selection.ActiveRegionChanged += ActiveRegionChanged; sheet.Rows.Inserted += HandleRowColInserted; sheet.Columns.Inserted += HandleRowColInserted; sheet.Rows.Removed += HandleRowColRemoved; @@ -458,6 +462,28 @@ private void AddEvents(Sheet sheet) sheet.SetDialogService(new SimpleDialogService(Js)); } + private async void ActiveRegionChanged(object? sender, ActiveRegionChangedEvent e) + { + var oldActiveRegion = e.OldRegion; + var activeRegion = e.NewRegion; + + if (activeRegion == null) + return; + + if (oldActiveRegion == null) + { + await ScrollToContainRegion(activeRegion); + return; + } + + var newRegions = activeRegion.Area > oldActiveRegion.Area + ? activeRegion.Break(oldActiveRegion) + : oldActiveRegion.Break(activeRegion); + + if (newRegions.Count == 1) + await ScrollToContainRegion(newRegions[0]); + } + private async Task AddWindowEventsAsync() { await _windowEventService.RegisterMouseEvent("mousedown", HandleWindowMouseDown); @@ -712,7 +738,6 @@ private async Task HandleWindowMouseUp(MouseEventArgs arg) private async Task HandleArrowKeysDown(bool shift, Offset offset) { var accepted = true; - var oldActiveRegion = _sheet.Selection.ActiveRegion?.Clone(); if (_sheet.Editor.IsEditing) accepted = _sheet.Editor.IsSoftEdit && _sheet.Editor.AcceptEdit(); @@ -724,20 +749,6 @@ private async Task HandleArrowKeysDown(bool shift, Offset offset) if (!shift && IsDataSheetActive) await ScrollToActiveCellPosition(); - var activeRegion = _sheet.Selection.ActiveRegion; - - if (shift && IsDataSheetActive && oldActiveRegion != null && activeRegion != null) - { - var newRegions = activeRegion.Area > oldActiveRegion.Area - ? activeRegion.Break(oldActiveRegion) - : oldActiveRegion.Break(activeRegion); - - if (newRegions.Count == 1) - { - await ScrollToContainRegion(newRegions.First()); - } - } - return true; } @@ -870,32 +881,67 @@ public async Task ScrollToContainRegion(IRegion region) var regionRect = region.GetRect(_sheet); double scrollYDist = 0, scrollXDist = 0; + var moveLeft = regionRect.X < constrainedViewRect.X; + var moveRight = regionRect.Right > constrainedViewRect.Right; + var moveUp = regionRect.Y < constrainedViewRect.Y; + var moveDown = regionRect.Bottom > constrainedViewRect.Bottom; + // If the region is outside the contained view rect but NOT within the frozen cols - if ((regionRect.X < constrainedViewRect.X || regionRect.Right > constrainedViewRect.Right) && - !(region.Left <= _frozenLeftCount - 1 || region.Right >= _sheet.NumCols - _frozenRightCount)) + if ((moveLeft || moveRight) && + !(region.Right <= _frozenLeftCount - 1 || region.Left >= _sheet.NumCols - _frozenRightCount)) { + doScroll = true; + var rightDist = regionRect.Right - constrainedViewRect.Right; var leftDist = regionRect.X - constrainedViewRect.X; - scrollXDist = Math.Abs(rightDist) < Math.Abs(leftDist) - ? rightDist - : leftDist; - - doScroll = true; + if (moveRight && moveLeft) + { + doScroll = false; + } + else if (moveRight) + { + // left edge distance should not end up closer to left edge than the right edge distance + if (Math.Abs(regionRect.Right + rightDist - constrainedViewRect.Right) < + Math.Abs(regionRect.X + rightDist - constrainedViewRect.X)) + scrollXDist = rightDist; + } + else if (moveLeft) + { + // right edge distance should not end up closer than the left edge distance + if (Math.Abs(regionRect.X + leftDist - constrainedViewRect.X) < + Math.Abs(regionRect.Right + leftDist - constrainedViewRect.Right)) + scrollXDist = leftDist; + } } // If the region is outside the contained view rect but NOT within the frozen rows - if ((regionRect.Y < constrainedViewRect.Y || regionRect.Bottom > constrainedViewRect.Bottom) - && !(region.Bottom <= _frozenTopCount - 1 || region.Top >= _sheet.NumRows - _frozenBottomCount)) + if ((moveUp || moveDown) && + !(region.Bottom <= _frozenTopCount - 1 || region.Top >= _sheet.NumRows - _frozenBottomCount)) { + doScroll = true; + var bottomDist = regionRect.Bottom - constrainedViewRect.Bottom; var topDist = regionRect.Y - constrainedViewRect.Y; - scrollYDist = Math.Abs(bottomDist) < Math.Abs(topDist) - ? bottomDist - : topDist; - - doScroll = true; + if (moveUp && moveDown) + { + doScroll = false; + } + else if (moveDown) + { + // top edge distance should not end up closer to top edge than the right edge distance + if (Math.Abs(regionRect.Bottom + bottomDist - constrainedViewRect.Bottom) < + Math.Abs(regionRect.Y + bottomDist - constrainedViewRect.Y)) + scrollYDist = bottomDist; + } + else if (moveUp) + { + // right edge distance should not end up closer than the left edge distance + if (Math.Abs(regionRect.Y + topDist - constrainedViewRect.Y) < + Math.Abs(regionRect.Bottom + topDist - constrainedViewRect.Bottom)) + scrollYDist = topDist; + } } if (doScroll) diff --git a/src/BlazorDatasheet/Render/SelectionInputManager.cs b/src/BlazorDatasheet/Render/SelectionInputManager.cs index 21609967..e120ab21 100644 --- a/src/BlazorDatasheet/Render/SelectionInputManager.cs +++ b/src/BlazorDatasheet/Render/SelectionInputManager.cs @@ -30,7 +30,7 @@ private void CollapseAndMoveSelection(Offset offset) return; var posn = _selection.ActiveCellPosition; - + _selection.Set(posn.row, posn.col); _selection.MoveActivePositionByRow(offset.Rows); _selection.MoveActivePositionByCol(offset.Columns); From ab0d8c42e722a1721d2104393aa876e2d8bfbf5b Mon Sep 17 00:00:00 2001 From: anmcgrath Date: Mon, 3 Nov 2025 16:44:08 +1100 Subject: [PATCH 8/8] Add method for setting active cell position (Activate) --- .../Selecting/Selection.cs | 34 +++++++++++++++++-- .../SheetTests/SelectionTests.cs | 28 +++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/BlazorDatasheet.Core/Selecting/Selection.cs b/src/BlazorDatasheet.Core/Selecting/Selection.cs index ed64d208..2b807210 100644 --- a/src/BlazorDatasheet.Core/Selecting/Selection.cs +++ b/src/BlazorDatasheet.Core/Selecting/Selection.cs @@ -471,7 +471,7 @@ public void MoveActivePositionByRow(int rowDir) { if (ActiveRegion == null || rowDir == 0) return; - + var oldRegions = CloneRegions(); rowDir = Math.Sign(rowDir); @@ -570,7 +570,7 @@ public void MoveActivePositionByCol(int colDir) { if (ActiveRegion == null || colDir == 0) return; - + var oldRegions = CloneRegions(); colDir = Math.Sign(colDir); @@ -738,6 +738,36 @@ internal void SetActiveCellPosition(int row, int col) ActiveCellPositionChanged?.Invoke(this, setEventArgs); } + /// + /// Makes the active cell position , + /// + /// + /// + public void Activate(int row, int col) + { + if (ActiveRegion == null || !_regions.Any()) + return; + + if (ActiveRegion.Contains(row, col)) + { + SetActiveCellPosition(row, col); + return; + } + + for (int i = 0; i < _regions.Count; i++) + { + var region = _regions[i]; + if (region.Contains(row, col)) + { + SetActiveRegionIndex(i); + SetActiveCellPosition(row, col); + return; + } + } + + Set(row, col); + } + internal SelectionSnapshot GetSelectionSnapshot() { var regions = _regions.Select(x => x.Clone()).ToList(); diff --git a/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs b/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs index 64e2915f..76ec01e6 100644 --- a/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs +++ b/test/BlazorDatasheet.Test/SheetTests/SelectionTests.cs @@ -288,4 +288,32 @@ public void Setting_Active_Region_In_Various_Ways_Fires_Active_Region_Event() oldRegion.Should().BeEquivalentTo(initialRegion); } } + + [Test] + public void Active_Position_Inside_Active_Region_Should_Set_To_PositionSpecified() + { + _sheet.Selection.Set(new List() { new Region(4, 5, 4, 5), new Region(0, 2, 0, 1) }); + _sheet.Selection.Activate(1, 1); + _sheet.Selection.ActiveRegion.Left.Should().Be(0); // + _sheet.Selection.ActiveCellPosition.Should().Be(new CellPosition(1, 1)); + } + + [Test] + public void Active_Position_Inside_Non_Active_Region_Should_Set_To_Active_Region() + { + _sheet.Selection.Set(new List() { new Region(4, 5, 4, 5), new Region(0, 2, 0, 1) }); + _sheet.Selection.Activate(4, 5); + _sheet.Selection.ActiveRegion.Left.Should().Be(4); // + _sheet.Selection.ActiveCellPosition.Should().Be(new CellPosition(4, 5)); + } + + [Test] + public void Active_Position_Inside_No_Regions_Should_Set_Active_region_and_position_appropriately() + { + _sheet.Selection.Set(new List() { new Region(4, 5, 4, 5), new Region(0, 2, 0, 1) }); + _sheet.Selection.Activate(9, 9); + _sheet.Selection.ActiveRegion.Should().BeEquivalentTo(new Region(9, 9)); + _sheet.Selection.ActiveCellPosition.Should().Be(new CellPosition(9, 9)); + _sheet.Selection.Regions.Count.Should().Be(1); + } } \ No newline at end of file