Focus View Grid layout and resizable components (#1558) (Closes #1552)

Co-authored-by: Alexandru Macocian <amacocian@microsoft.com>
This commit is contained in:
2026-06-29 20:10:45 +02:00
parent 9d7f145c25
commit a61cff8dfd
15 changed files with 741 additions and 91 deletions
@@ -39,4 +39,33 @@ public sealed class FocusViewOptions
[JsonPropertyName(nameof(EnergyDisplay))]
[OptionName(Name = "Energy Display Mode", Description = "Sets how should the energy display show the information")]
public PointsDisplay EnergyDisplay { get; set; }
[JsonPropertyName(nameof(GridColumns))]
[OptionName(Name = "Layout Grid Columns", Description = "Number of columns in the focus view layout grid")]
[OptionIgnore]
public int GridColumns { get; set; } = DefaultGridColumns;
[JsonPropertyName(nameof(GridRows))]
[OptionName(Name = "Layout Grid Rows", Description = "Number of rows in the focus view layout grid")]
[OptionIgnore]
public int GridRows { get; set; } = DefaultGridRows;
[JsonPropertyName(nameof(Layout))]
[OptionName(Name = "Layout", Description = "Tile-based layout of the focus view components")]
public List<FocusViewTile> Layout { get; set; } = DefaultLayout();
public const int DefaultGridColumns = 20;
public const int DefaultGridRows = 16;
public static List<FocusViewTile> DefaultLayout() =>
[
new() { Component = FocusViewComponent.Character, Column = 1, Row = 1, ColumnSpan = 10, RowSpan = 3 },
new() { Component = FocusViewComponent.PlayerResources, Column = 1, Row = 4, ColumnSpan = 10, RowSpan = 3 },
new() { Component = FocusViewComponent.Vanquish, Column = 1, Row = 7, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.Title, Column = 1, Row = 8, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.CurrentMap, Column = 1, Row = 9, ColumnSpan = 10, RowSpan = 1 },
new() { Component = FocusViewComponent.QuestLog, Column = 1, Row = 10, ColumnSpan = 10, RowSpan = 7 },
new() { Component = FocusViewComponent.Build, Column = 11, Row = 1, ColumnSpan = 10, RowSpan = 5 },
new() { Component = FocusViewComponent.Browser, Column = 11, Row = 6, ColumnSpan = 10, RowSpan = 11 },
];
}
@@ -1,6 +1,23 @@
@if (this.Context is not null)
{
<div class="container backdrop-panel">
<div class="container backdrop-panel">
@if (this.Context is null)
{
<SpinnerWidget IsLoading="true" />
}
else if (this.Context.Title is null)
{
<div class="title-track-container">
<div class="title-label">
Title Track
</div>
</div>
<div class="title-bar-container">
<div class="title-idle">
No active title
</div>
</div>
}
else
{
<div class="title-track-container">
<div class="title-label">
Title Track
@@ -18,8 +35,8 @@
</div>
</div>
</div>
</div>
}
}
</div>
@code {
[Parameter]
@@ -69,7 +86,7 @@
private string GetTitleDescription()
{
if (this.Context is null)
if (this.Context?.Title is null)
{
return string.Empty;
}
@@ -54,3 +54,15 @@
color: var(--neutral-foreground-rest);
z-index: 1;
}
.title-idle {
width: 100%;
height: var(--font-size-x-large);
display: flex;
align-items: center;
justify-content: center;
background: var(--neutral-fill-rest);
border-radius: 0px 0px 8px 8px;
font-size: var(--font-size-medium);
color: var(--neutral-foreground-hint);
}
@@ -1,21 +1,34 @@
@if (this.Context is not null &&
this.Context.HardMode &&
this.Context.Vanquishing)
{
<div class="container backdrop-panel">
<div class="container backdrop-panel">
@if (this.Context is null)
{
<SpinnerWidget IsLoading="true" />
}
else
{
<div class="title">
Vanquishing
</div>
<div class="vanquish-bar-container">
<div class="vanquish-bar" @onclick="@(() => this.VanquishBarClicked())">
<div class="vanquish-bar-fill" style="@(this.GetVanquishBarWidth())"></div>
<div class="vanquish-bar-text">
@this.GetVanquishBarText()
@if (this.Context.HardMode && this.Context.Vanquishing)
{
<div class="vanquish-bar-container">
<div class="vanquish-bar" @onclick="@(() => this.VanquishBarClicked())">
<div class="vanquish-bar-fill" style="@(this.GetVanquishBarWidth())"></div>
<div class="vanquish-bar-text">
@this.GetVanquishBarText()
</div>
</div>
</div>
</div>
</div>
}
}
else
{
<div class="vanquish-bar-container">
<div class="vanquish-idle">
Not vanquishing
</div>
</div>
}
}
</div>
@code {
[Parameter]
@@ -37,6 +50,11 @@
}
var totalFoes = this.Context.FoesToKill + this.Context.FoesKilled;
if (totalFoes is 0)
{
return "width: 0%;";
}
return $"width: {this.Context.FoesKilled * 100d / totalFoes}%;";
}
@@ -40,3 +40,12 @@
color: var(--neutral-foreground-rest);
z-index: 1;
}
.vanquish-idle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--font-size-medium);
color: var(--neutral-foreground-hint);
}
+197 -42
View File
@@ -1,50 +1,82 @@
@using Daybreak.Views.Components.FocusView
@using Daybreak.Shared.Models.FocusView
@using Daybreak.Views.Components.FocusView
@using Microsoft.JSInterop
@page "/focus"
@inherits ViewBase<FocusView, FocusViewModel>
<div class="focus-dashboard">
<div class="dashboard-half">
<div class="component character-component">
<CharacterComponent Context="@this.ViewModel.CharacterComponentContext"
OnCharacterChanged="@this.ViewModel.OnCharacterChanged"
OnExperienceBarClicked="@this.ViewModel.OnExperienceDisplayClicked" />
</div>
<div class="component resources-component">
<PlayerResourcesComponent Context="@this.ViewModel.PlayerResourcesComponentContext"
OnBalthazarBarClicked="@this.ViewModel.OnBalthazarBarClicked"
OnKurzickBarClicked="@this.ViewModel.OnKurzickBarClicked"
OnImperialBarClicked="@this.ViewModel.OnImperialBarClicked"
OnLuxonBarClicked="@this.ViewModel.OnLuxonBarClicked" />
</div>
<div class="component vanquish-component">
<VanquishComponent Context="@this.ViewModel.VanquishComponentContext"
OnVanquishBarClicked="@this.ViewModel.OnVanquishingDisplayClicked" />
</div>
<div class="component title-component">
<TitleInformationComponent Context="@this.ViewModel.TitleInformationComponentContext"
OnTitleClicked="@this.ViewModel.OnTitleClicked" />
</div>
<div class="component map-component">
<CurrentMapComponent Context="@this.ViewModel.CurrentMapComponentContext"
OnCurrentMapClicked="@this.ViewModel.OnCurrentMapClicked"/>
</div>
<div class="component quest-component">
<QuestLogComponent Context="@this.ViewModel.QuestLogComponentContext"
OnQuestClicked="@this.ViewModel.OnQuestClicked"/>
</div>
<div class="focus-dashboard @(this.ViewModel.IsEditingLayout ? "editing" : string.Empty)">
<div class="title-bar-additional-btns-left">
@if (this.ViewModel.IsEditingLayout)
{
<button class="title-bar-btn" @onclick="@this.SaveLayoutAsync" title="Save layout">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Save())" />
</button>
<button class="title-bar-btn" @onclick="@this.ViewModel.ResetLayout" title="Reset layout to defaults">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.ArrowUndo())" />
</button>
<div class="add-component-wrapper">
<button class="title-bar-btn"
title="Add component"
disabled="@(!this.ViewModel.HiddenComponents.Any())"
@onclick="@this.ToggleAddDropdown">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Add())" />
</button>
@if (this.isAddDropdownOpen && this.ViewModel.HiddenComponents.Any())
{
<div class="add-component-menu">
@foreach (var component in this.ViewModel.HiddenComponents)
{
<div class="add-component-item" @onclick="@(() => this.AddComponentAsync(component))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Eye())" />
<span>@TileName(component)</span>
</div>
}
</div>
}
</div>
}
else
{
<button class="title-bar-btn" @onclick="@this.EnterEditLayout" title="Edit layout">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.SquareMultiple())" />
</button>
}
</div>
<div class="dashboard-half">
<div class="component build-component">
<BuildComponent Context="@this.ViewModel.BuildComponentContext"
OnBuildClicked="@this.ViewModel.OnBuildClicked"
OnLoadPlayerSingleBuildClicked="@this.ViewModel.OnPlayerSingleBuildClicked"
OnLoadPlayerTeamBuildClicked="@this.ViewModel.OnPlayerTeamBuildClicked" />
</div>
<div class="component browser-component">
<iframe sandbox="allow-scripts allow-same-origin allow-forms"
style="height: 100%; width: 100%; margin: -2px;"
src="@this.ViewModel.BrowserSource" />
<div class="focus-content">
<div class="grid-stack" @ref="this.gridElement"
style="--focus-cols: @this.ViewModel.GridColumns; --focus-rows: @this.ViewModel.GridRows;">
@foreach (var tile in this.ViewModel.VisibleTiles)
{
<div class="grid-stack-item"
@key="tile.Component"
data-component="@tile.Component"
gs-x="@(tile.Column - 1)"
gs-y="@(tile.Row - 1)"
gs-w="@tile.ColumnSpan"
gs-h="@tile.RowSpan">
<div class="grid-stack-item-content component component-@(tile.Component.ToString().ToLowerInvariant())">
@if (this.ViewModel.IsEditingLayout)
{
<div class="focus-tile-placeholder">
<span class="focus-tile-name">@TileName(tile.Component)</span>
<button class="tile-remove-button"
title="Hide @TileName(tile.Component)"
@onclick="@(() => this.ToggleComponentAsync(tile.Component))">
<FluentIcon Value="@(new Microsoft.FluentUI.AspNetCore.Components.Icons.Regular.Size16.Dismiss())" />
</button>
</div>
}
else
{
<div class="focus-tile-body">
@this.RenderTile(tile.Component)
</div>
}
</div>
</div>
}
</div>
</div>
</div>
@@ -54,4 +86,127 @@
public required string ConfigurationId { get; init; }
[Parameter]
public required string ProcessId { get; init; }
[Inject]
public required IJSRuntime JSRuntime { get; init; }
private ElementReference gridElement;
private int lastSyncedVersion = -1;
private bool isAddDropdownOpen;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (this.ViewModel.Tiles.Count == 0)
{
return;
}
if (this.ViewModel.LayoutVersion != this.lastSyncedVersion)
{
this.lastSyncedVersion = this.ViewModel.LayoutVersion;
this.ViewModel.MarkLayoutSynced();
await this.JSRuntime.InvokeVoidAsync("focusGrid.init", this.gridElement, new
{
columns = this.ViewModel.GridColumns,
rows = this.ViewModel.GridRows,
editing = this.ViewModel.IsEditingLayout
});
}
}
// Pulls the current geometry from Gridstack into the view model. Called only at meaningful
// moments (save / show / hide) so live drag/resize is never interrupted by a round-trip.
private async Task SyncFromGridAsync()
{
if (this.ViewModel.Tiles.Count == 0)
{
return;
}
var changes = await this.JSRuntime.InvokeAsync<FocusTileChange[]>("focusGrid.save");
this.ViewModel.ApplyTileChanges(changes);
}
private async Task SaveLayoutAsync()
{
await this.SyncFromGridAsync();
this.isAddDropdownOpen = false;
this.ViewModel.SaveLayout();
}
private void EnterEditLayout()
{
this.isAddDropdownOpen = false;
this.ViewModel.ToggleEditLayout();
}
private void ToggleAddDropdown() => this.isAddDropdownOpen = !this.isAddDropdownOpen;
private async Task ToggleComponentAsync(FocusViewComponent component)
{
await this.SyncFromGridAsync();
this.ViewModel.ToggleTileVisibility(component);
}
private async Task AddComponentAsync(FocusViewComponent component)
{
await this.SyncFromGridAsync();
this.ViewModel.ToggleTileVisibility(component);
if (!this.ViewModel.HiddenComponents.Any())
{
this.isAddDropdownOpen = false;
}
}
private RenderFragment RenderTile(FocusViewComponent component) => component switch
{
FocusViewComponent.Character => @<CharacterComponent Context="@this.ViewModel.CharacterComponentContext"
OnCharacterChanged="@this.ViewModel.OnCharacterChanged"
OnExperienceBarClicked="@this.ViewModel.OnExperienceDisplayClicked" />,
FocusViewComponent.PlayerResources => @<PlayerResourcesComponent Context="@this.ViewModel.PlayerResourcesComponentContext"
OnBalthazarBarClicked="@this.ViewModel.OnBalthazarBarClicked"
OnKurzickBarClicked="@this.ViewModel.OnKurzickBarClicked"
OnImperialBarClicked="@this.ViewModel.OnImperialBarClicked"
OnLuxonBarClicked="@this.ViewModel.OnLuxonBarClicked" />,
FocusViewComponent.Vanquish => @<VanquishComponent Context="@this.ViewModel.VanquishComponentContext"
OnVanquishBarClicked="@this.ViewModel.OnVanquishingDisplayClicked" />,
FocusViewComponent.Title => @<TitleInformationComponent Context="@this.ViewModel.TitleInformationComponentContext"
OnTitleClicked="@this.ViewModel.OnTitleClicked" />,
FocusViewComponent.CurrentMap => @<CurrentMapComponent Context="@this.ViewModel.CurrentMapComponentContext"
OnCurrentMapClicked="@this.ViewModel.OnCurrentMapClicked" />,
FocusViewComponent.QuestLog => @<QuestLogComponent Context="@this.ViewModel.QuestLogComponentContext"
OnQuestClicked="@this.ViewModel.OnQuestClicked" />,
FocusViewComponent.Build => @<BuildComponent Context="@this.ViewModel.BuildComponentContext"
OnBuildClicked="@this.ViewModel.OnBuildClicked"
OnLoadPlayerSingleBuildClicked="@this.ViewModel.OnPlayerSingleBuildClicked"
OnLoadPlayerTeamBuildClicked="@this.ViewModel.OnPlayerTeamBuildClicked" />,
FocusViewComponent.Browser => @<iframe sandbox="allow-scripts allow-same-origin allow-forms"
style="height: 100%; width: 100%; margin: -2px;"
src="@this.ViewModel.BrowserSource" />,
_ => @<text></text>
};
private static string TileName(FocusViewComponent component) => component switch
{
FocusViewComponent.Character => "Character",
FocusViewComponent.PlayerResources => "Resources",
FocusViewComponent.Vanquish => "Vanquish",
FocusViewComponent.Title => "Title",
FocusViewComponent.CurrentMap => "Current Map",
FocusViewComponent.QuestLog => "Quest Log",
FocusViewComponent.Build => "Builds",
FocusViewComponent.Browser => "Browser",
_ => component.ToString()
};
protected override void Dispose(bool disposing)
{
if (disposing)
{
_ = this.JSRuntime.InvokeVoidAsync("focusGrid.destroy");
}
base.Dispose(disposing);
}
}
+133 -2
View File
@@ -63,6 +63,115 @@ public sealed class FocusViewModel(
public BuildComponentContext? BuildComponentContext { get; private set; }
public string? BrowserSource { get; private set; } = "https://wiki.guildwars.com/wiki/Main_Page";
private List<FocusViewTile> tiles = [];
public bool IsEditingLayout { get; private set; }
public bool LayoutInitialized { get; private set; }
public int LayoutVersion { get; private set; }
public IReadOnlyList<FocusViewTile> Tiles => this.tiles;
public IEnumerable<FocusViewTile> VisibleTiles => this.tiles.Where(t => t.Visible);
public IEnumerable<FocusViewComponent> HiddenComponents => this.tiles.Where(t => !t.Visible).Select(t => t.Component);
public int GridColumns => FocusViewOptions.DefaultGridColumns;
public int GridRows => FocusViewOptions.DefaultGridRows;
public void ToggleEditLayout()
{
this.IsEditingLayout = !this.IsEditingLayout;
this.LayoutVersion++;
this.RefreshView();
}
public void SaveLayout()
{
this.PersistLayout();
this.IsEditingLayout = false;
this.LayoutVersion++;
this.RefreshView();
}
public bool IsTileVisible(FocusViewComponent component) =>
this.tiles.FirstOrDefault(t => t.Component == component)?.Visible ?? false;
public void ToggleTileVisibility(FocusViewComponent component)
{
var tile = this.tiles.FirstOrDefault(t => t.Component == component);
if (tile is null)
{
return;
}
tile.Visible = !tile.Visible;
this.LayoutVersion++;
this.PersistLayout();
this.RefreshView();
}
public void ApplyTileChanges(IEnumerable<FocusTileChange> changes)
{
var columns = this.GridColumns;
var rows = this.GridRows;
foreach (var change in changes)
{
if (!Enum.TryParse<FocusViewComponent>(change.Component, out var component))
{
continue;
}
var tile = this.tiles.FirstOrDefault(t => t.Component == component);
if (tile is null)
{
continue;
}
tile.ColumnSpan = Math.Clamp(change.ColumnSpan, 1, columns);
tile.RowSpan = Math.Clamp(change.RowSpan, 1, rows);
tile.Column = Math.Clamp(change.Column, 1, columns - tile.ColumnSpan + 1);
tile.Row = Math.Clamp(change.Row, 1, rows - tile.RowSpan + 1);
}
}
public void ResetLayout()
{
this.tiles = FocusViewOptions.DefaultLayout();
this.IsEditingLayout = false;
this.LayoutVersion++;
this.PersistLayout();
this.RefreshView();
}
public void MarkLayoutSynced() => this.LayoutInitialized = true;
private void LoadLayout()
{
var savedLayout = this.options.CurrentValue.Layout ?? [];
var normalized = new List<FocusViewTile>();
var defaults = FocusViewOptions.DefaultLayout();
var columns = this.GridColumns;
var rows = this.GridRows;
foreach (var component in Enum.GetValues<FocusViewComponent>())
{
var tile = savedLayout.FirstOrDefault(t => t.Component == component)
?? defaults.First(t => t.Component == component);
tile.ColumnSpan = Math.Clamp(tile.ColumnSpan, 1, columns);
tile.RowSpan = Math.Clamp(tile.RowSpan, 1, rows);
tile.Column = Math.Clamp(tile.Column, 1, columns - tile.ColumnSpan + 1);
tile.Row = Math.Clamp(tile.Row, 1, rows - tile.RowSpan + 1);
normalized.Add(tile);
}
this.tiles = normalized;
this.PersistLayout();
}
private void PersistLayout()
{
var options = this.options.CurrentValue;
options.GridColumns = this.GridColumns;
options.GridRows = this.GridRows;
options.Layout = this.tiles;
this.optionsProvider.SaveOption(options);
}
public override async ValueTask ParametersSet(FocusView view, CancellationToken cancellationToken)
{
var scopedLogger = this.logger.CreateScopedLogger();
@@ -291,6 +400,7 @@ public sealed class FocusViewModel(
this.cancellationSource?.Dispose();
this.cancellationSource = new CancellationTokenSource();
var ct = this.cancellationSource.Token;
this.LoadLayout();
this.SetupDefaultValues();
Task.Factory.StartNew(() => this.PeriodicallyFetchGameInformation(ct), ct, TaskCreationOptions.LongRunning, TaskScheduler.Current);
}
@@ -344,7 +454,10 @@ public sealed class FocusViewModel(
else
{
retryCount = 0;
await this.RefreshViewAsync();
if (!this.IsEditingLayout)
{
await this.RefreshViewAsync();
}
}
}
catch (Exception ex)
@@ -602,7 +715,16 @@ public sealed class FocusViewModel(
if (titleInfo is null ||
!Title.TryParse((int)titleInfo.Id, out var title))
{
return default;
return new TitleInformationComponentContext
{
Title = default,
CurrentPoints = 0,
IsPercentage = false,
MaxTierNumber = 0,
TierNumber = 0,
PointsForCurrentRank = 0,
PointsForNextRank = 0
};
}
return new TitleInformationComponentContext
@@ -657,3 +779,12 @@ public sealed class FocusViewModel(
};
}
}
public sealed record FocusTileChange
{
public required string Component { get; init; }
public required int Column { get; init; }
public required int Row { get; init; }
public required int ColumnSpan { get; init; }
public required int RowSpan { get; init; }
}
+168 -27
View File
@@ -1,56 +1,197 @@
.focus-dashboard {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: calc(100% - 55px);
margin-top: 35px;
height: 100%;
overflow: hidden;
}
.dashboard-half {
.focus-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
width: 50%;
height: 100%;
margin-top: 35px;
margin-bottom: 20px;
overflow: hidden;
}
.component {
.grid-stack {
flex: 1;
min-height: 0;
width: 100%;
position: relative;
}
.grid-stack-item-content {
display: flex;
flex-direction: column;
inset: 0;
border-radius: 8px;
background: transparent;
}
/* Gridstack's own rule (.grid-stack>.grid-stack-item>.grid-stack-item-content) forces
overflow-y:auto with high specificity, which makes tiles scroll. Override it at equal
specificity so tiles clip instead. */
.grid-stack > .grid-stack-item > .grid-stack-item-content {
overflow: clip;
}
.character-component {
flex-shrink: 0;
overflow: visible !important;
.focus-tile-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.focus-tile-body > *,
.focus-tile-body ::deep > * {
flex: 1;
min-height: 0;
height: 100%;
}
/* The character select dropdown overflows its tile, so it must not be clipped or scrolled.
Needs to outrank gridstack's own .grid-stack>.grid-stack-item>.grid-stack-item-content rule. */
.grid-stack > .grid-stack-item > .grid-stack-item-content.component-character {
overflow: visible;
}
.component-character .focus-tile-body {
overflow: visible;
}
.component-character {
z-index: 1000;
}
.vanquish-component {
flex-shrink: 0;
/* In edit mode the live Character dropdown is not rendered (only the placeholder), so drop its
raised z-index/overflow which would otherwise let it sit above the resize grabber. */
.focus-dashboard.editing .grid-stack > .grid-stack-item > .grid-stack-item-content.component-character {
overflow: clip;
z-index: auto;
}
.resources-component {
flex-shrink: 0;
/* Editing mode visuals */
.focus-dashboard.editing .grid-stack {
background-image:
linear-gradient(to right, var(--neutral-stroke-rest) 1px, transparent 1px),
linear-gradient(to bottom, var(--neutral-stroke-rest) 1px, transparent 1px);
background-size: calc(100% / var(--focus-cols)) calc(100% / var(--focus-rows));
background-position: 0 0;
}
.title-component {
flex-shrink: 0;
.focus-dashboard.editing .grid-stack-item-content {
outline: 1px dashed var(--accent-stroke-control-rest);
outline-offset: -1px;
cursor: move;
}
.map-component {
flex-shrink: 0;
/* In edit mode each tile is replaced by a full-size name placeholder so the
layout is not distorted by the live components (which require game data). */
.focus-tile-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-fill-rest);
color: var(--foreground-on-accent-rest);
user-select: none;
overflow: hidden;
}
.quest-component {
flex: 1;
min-height: 0;
.focus-tile-name {
font-size: var(--font-size-base);
font-weight: 600;
text-align: center;
padding: 0 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.build-component {
height: 35%;
flex-shrink: 0;
/* Larger, easier-to-grab resize handle in the bottom-right corner. z-index sits above any
component (e.g. Character uses z-index:1000 for its dropdown) so the grabber is always visible. */
.focus-dashboard.editing .grid-stack-item > .ui-resizable-se {
width: 18px;
height: 18px;
right: 2px;
bottom: 2px;
background: transparent;
border-right: 3px solid var(--foreground-on-accent-rest);
border-bottom: 3px solid var(--foreground-on-accent-rest);
border-bottom-right-radius: 4px;
transform: none;
cursor: se-resize;
z-index: 1001;
}
.browser-component {
height: 65%;
flex-shrink: 0;
}
/* Per-tile remove button (top-right) to hide a component while editing. */
.tile-remove-button {
position: absolute;
top: 4px;
right: 4px;
z-index: 1002;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
cursor: pointer;
background: rgba(0, 0, 0, 0.45);
color: #fff;
}
.tile-remove-button:hover {
background: var(--error, #c50f1f);
}
/* Add-component dropdown, anchored to the titlebar button (mirrors the character dropdown). */
.add-component-wrapper {
position: relative;
display: inline-flex;
}
.title-bar-btn:disabled {
opacity: 0.4;
cursor: default;
pointer-events: none;
}
.add-component-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 180px;
max-height: 320px;
overflow-y: auto;
background: var(--neutral-fill-rest);
border: 1px solid var(--neutral-stroke-rest);
border-radius: 6px;
z-index: 20002;
}
.add-component-item {
display: flex;
align-items: center;
gap: 8px;
padding: 0.5rem 0.75rem;
cursor: pointer;
color: var(--neutral-foreground-rest);
border-bottom: 1px solid var(--neutral-stroke-rest);
}
.add-component-item:last-child {
border-bottom: none;
}
.add-component-item:hover {
background: var(--neutral-fill-hover);
}
+3
View File
@@ -7,6 +7,7 @@
<title>Daybreak</title>
<base href="/" />
<link href="css/site.css" rel="stylesheet" />
<link href="deps/gridstack/12.6.0/gridstack.min.css" rel="stylesheet" />
<link href="Daybreak.styles.css" rel="stylesheet" />
</head>
@@ -14,9 +15,11 @@
<div id="app"></div>
<script src="_framework/blazor.webview.js"></script>
<script src="deps/marked.js/15.0.12/marked.min.js"></script>
<script src="deps/gridstack/12.6.0/gridstack-all.js"></script>
<script src="js/wiki-renderer.js"></script>
<script src="js/hover-delay-popup.js"></script>
<script src="js/rendering.js"></script>
<script src="js/focus-grid.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0;top:0;width:var(--gs-column-width);height:var(--gs-cell-height)}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack:not(.grid-stack-rtl)>.grid-stack-item{left:0}.grid-stack.grid-stack-rtl>.grid-stack-item{right:0}.grid-stack>.grid-stack-item>.grid-stack-item-content,.grid-stack>.grid-stack-placeholder>.placeholder-content{top:var(--gs-item-margin-top);right:var(--gs-item-margin-right);bottom:var(--gs-item-margin-bottom);left:var(--gs-item-margin-left)}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:var(--gs-item-margin-top);left:var(--gs-item-margin-left)}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:var(--gs-item-margin-top);left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:var(--gs-item-margin-top);right:var(--gs-item-margin-right)}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px;right:var(--gs-item-margin-right)}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px;bottom:var(--gs-item-margin-bottom);right:var(--gs-item-margin-right)}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:var(--gs-item-margin-bottom);right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px;bottom:var(--gs-item-margin-bottom);left:var(--gs-item-margin-left)}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px;left:var(--gs-item-margin-left)}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,right,top}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,right .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,right 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack:not(.grid-stack-rtl)>.grid-stack-item[gs-x="0"]{left:0}.grid-stack.grid-stack-rtl>.grid-stack-item[gs-x="0"]{right:0}
+94
View File
@@ -0,0 +1,94 @@
// Interop bridge between the Blazor FocusView and the vendored Gridstack.js library.
//
// Ownership model (kept deliberately simple to avoid the two engines fighting each other):
// * Blazor owns the tile *markup* and renders the initial geometry as gs-x/gs-y/gs-w/gs-h.
// * While editing, Gridstack owns drag/resize *entirely* and runs natively for the whole
// session - there are no per-move callbacks into .NET, which is what kept the previous
// implementation feeling janky.
// * .NET pulls the final geometry on demand via save() (on Save / show / hide), never on
// every mouse move.
window.focusGrid = (() => {
let grid = null;
let resizeObserver = null;
let gridElement = null;
let gridRows = 0;
function computeCellHeight(element, rows) {
const height = element.clientHeight;
if (!height || !rows) {
return 'auto';
}
return Math.max(1, Math.floor(height / rows));
}
function init(element, options) {
if (!window.GridStack) {
console.error('GridStack library is not loaded');
return;
}
destroy();
gridElement = element;
gridRows = options.rows;
grid = window.GridStack.init({
column: options.columns,
cellHeight: computeCellHeight(element, options.rows),
margin: 2,
// float:false gives "gravity" - tiles compact upwards into any free space above them.
float: false,
disableDrag: !options.editing,
disableResize: !options.editing,
staticGrid: !options.editing,
draggable: { cancel: '.tile-remove-button' },
resizable: { handles: 'se' },
animate: true
}, element);
// Keep the cells filling the available height as the window/container resizes.
resizeObserver = new ResizeObserver(() => {
if (!grid || !gridElement) {
return;
}
grid.cellHeight(computeCellHeight(gridElement, gridRows));
});
resizeObserver.observe(element);
}
// Returns the current geometry of every tile so .NET can persist it. 1-based coordinates
// to match the FocusViewTile model.
function save() {
if (!grid) {
return [];
}
return grid.getGridItems().map(el => {
const node = el.gridstackNode || {};
return {
component: el.getAttribute('data-component'),
column: (node.x || 0) + 1,
row: (node.y || 0) + 1,
columnSpan: node.w || 1,
rowSpan: node.h || 1
};
});
}
function destroy() {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (grid) {
grid.destroy(false);
grid = null;
}
gridElement = null;
}
return { init, save, destroy };
})();
@@ -0,0 +1,13 @@
namespace Daybreak.Shared.Models.FocusView;
public enum FocusViewComponent
{
Character,
PlayerResources,
Vanquish,
Title,
CurrentMap,
QuestLog,
Build,
Browser
}
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace Daybreak.Shared.Models.FocusView;
public sealed class FocusViewTile
{
[JsonPropertyName(nameof(Component))]
public FocusViewComponent Component { get; set; }
[JsonPropertyName(nameof(Visible))]
public bool Visible { get; set; } = true;
[JsonPropertyName(nameof(Column))]
public int Column { get; set; } = 1;
[JsonPropertyName(nameof(Row))]
public int Row { get; set; } = 1;
[JsonPropertyName(nameof(ColumnSpan))]
public int ColumnSpan { get; set; } = 1;
[JsonPropertyName(nameof(RowSpan))]
public int RowSpan { get; set; } = 1;
}
@@ -3,7 +3,7 @@
namespace Daybreak.Shared.Models.FocusView;
public sealed class TitleInformationComponentContext
{
public required Title Title { get; init; }
public required Title? Title { get; init; }
public required bool IsPercentage { get; init; }
public required uint CurrentPoints { get; init; }
public required uint PointsForCurrentRank { get; init; }