Skip to content

Commit a8c7431

Browse files
committed
Make editor cards draggable and theme aware (#51)
1 parent de95289 commit a8c7431

8 files changed

Lines changed: 472 additions & 132 deletions

File tree

src/PrompterOne.Shared/AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656
- Editor dropdowns and tooltips must read as structured surfaces: item rows need a consistent visual rhythm with aligned columns or spacing, and overlay surfaces need border contrast strong enough to separate them clearly from the editor background.
5757
- Editor dropdown rows must stay compact menu rows, not stacks of tall rounded mini-cards; overlays may feel premium, but menu items still need fast scannable list rhythm.
5858
- Dropdown item content across `PrompterOne.Shared` must align from the left edge as one readable cluster; do not push tags, shortcuts, or meta copy to a fake right column inside menu rows.
59+
- Editor Cards view must behave like a real card board: script cards should support drag-and-drop reordering, expose clear drag handles, and persist the resulting source order instead of relying only on up/down buttons.
60+
- Editor Cards view must be theme-aware across light and dark themes; do not hardcode white card/page backgrounds or dark-only text that bypasses shared app color tokens.
5961
- Tooltip surfaces across the app must feel intentional and premium: compact, aligned, clearly separated from the background, and positioned so they do not clip, overlap, or awkwardly fight the control that owns them.
6062
- Editor header toolbar tooltips must reveal more slowly than dropdown intent so quick menu interactions open the dropdown without competing hover tooltip paint.
6163
- Repeated menus, dropdowns, tooltips, badges, icon rows, image wrappers, and similar visual chrome must be standardized as reusable Blazor components with owning styles; routed pages and catalog files must compose those components instead of embedding bespoke markup or inline visual logic.

src/PrompterOne.Shared/Contracts/UiTestIds.Editor.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ public static class Editor
191191

192192
public static string RenderedBlock(int segmentIndex, int blockIndex) => $"editor-rendered-block-{segmentIndex}-{blockIndex}";
193193

194+
public static string RenderedBlockDragHandle(int segmentIndex, int blockIndex) => $"editor-rendered-block-drag-handle-{segmentIndex}-{blockIndex}";
195+
194196
public static string RenderedBlockAttachment(int segmentIndex, int blockIndex, int attachmentIndex) =>
195197
$"editor-rendered-block-attachment-{segmentIndex}-{blockIndex}-{attachmentIndex}";
196198

src/PrompterOne.Shared/Editor/Components/EditorRenderedTextView.razor

Lines changed: 88 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
@namespace PrompterOne.Shared.Components.Editor
22
@using Microsoft.AspNetCore.Components.Forms
33
@using Microsoft.Extensions.Localization
4+
@using Microsoft.JSInterop
45
@using PrompterOne.Shared.Localization
6+
@implements IAsyncDisposable
57
@inject IStringLocalizer<SharedResource> Localizer
8+
@inject IJSRuntime JSRuntime
69

7-
<div class="editor-rendered-view" data-test="@UiTestIds.Editor.RenderedView">
10+
<div class="editor-rendered-view"
11+
@ref="_rootElement"
12+
data-test="@UiTestIds.Editor.RenderedView">
813
<div class="editor-rendered-strip" data-test="@UiTestIds.Editor.RenderedStrip">
914
@if (Segments.Count == 0)
1015
{
@@ -37,19 +42,15 @@
3742

3843
@foreach (var block in segment.Blocks)
3944
{
40-
<article class="@GetBlockCssClass(block)"
41-
draggable="true"
42-
@ondragstart="() => OnBlockDragStart(block)"
43-
@ondragend="OnBlockDragEnd"
44-
@ondragenter="() => OnBlockDragEnter(block)"
45-
@ondragleave="() => OnBlockDragLeave(block)"
46-
@ondragover:preventDefault="true"
47-
@ondrop="() => OnBlockDropAsync(block)"
48-
@ondrop:preventDefault="true"
45+
<article class="editor-rendered-block"
46+
draggable="false"
47+
data-rendered-segment-index="@block.SegmentIndex"
48+
data-rendered-block-index="@block.BlockIndex"
4949
data-test="@UiTestIds.Editor.RenderedBlock(block.SegmentIndex, block.BlockIndex)">
5050
<div class="editor-rendered-block-head">
5151
<span class="editor-rendered-block-drag-handle"
52-
aria-hidden="true">⋮⋮</span>
52+
aria-hidden="true"
53+
data-test="@UiTestIds.Editor.RenderedBlockDragHandle(block.SegmentIndex, block.BlockIndex)">⋮⋮</span>
5354
<span class="editor-rendered-block-number">@block.Number</span>
5455
<span class="editor-rendered-block-title">@block.Name</span>
5556
<span class="editor-rendered-block-actions">
@@ -133,9 +134,44 @@
133134

134135
[Parameter] public EventCallback<EditorRenderedBlockAttachmentRequest> AttachmentRequested { get; set; }
135136

136-
private EditorRenderedBlockViewModel? _draggedBlock;
137+
private const string RenderedCardsModulePath = "./_content/PrompterOne.Shared/app/editor-rendered-cards.js";
137138

138-
private EditorRenderedBlockViewModel? _dragOverBlock;
139+
private ElementReference _rootElement;
140+
141+
private IJSObjectReference? _renderedCardsModule;
142+
143+
private IJSObjectReference? _renderedCardsHandle;
144+
145+
private DotNetObjectReference<EditorRenderedTextView>? _dotNetReference;
146+
147+
protected override async Task OnAfterRenderAsync(bool firstRender)
148+
{
149+
if (!firstRender)
150+
{
151+
return;
152+
}
153+
154+
try
155+
{
156+
_dotNetReference = DotNetObjectReference.Create(this);
157+
_renderedCardsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", RenderedCardsModulePath);
158+
if (_renderedCardsModule is null)
159+
{
160+
return;
161+
}
162+
163+
_renderedCardsHandle = await _renderedCardsModule.InvokeAsync<IJSObjectReference>(
164+
"attach",
165+
_rootElement,
166+
_dotNetReference);
167+
}
168+
catch (InvalidOperationException)
169+
{
170+
}
171+
catch (JSException)
172+
{
173+
}
174+
}
139175

140176
private Task OnBlockInputAsync(EditorRenderedBlockViewModel block, ChangeEventArgs args) =>
141177
TextChanged.InvokeAsync(new EditorRenderedBlockTextChange(
@@ -158,50 +194,26 @@
158194
file));
159195
}
160196

161-
private void OnBlockDragStart(EditorRenderedBlockViewModel block)
162-
{
163-
_draggedBlock = block;
164-
_dragOverBlock = null;
165-
}
166-
167-
private void OnBlockDragEnter(EditorRenderedBlockViewModel block)
168-
{
169-
if (_draggedBlock is not null && !IsSameBlock(_draggedBlock, block))
170-
{
171-
_dragOverBlock = block;
172-
}
173-
}
174-
175-
private void OnBlockDragLeave(EditorRenderedBlockViewModel block)
176-
{
177-
if (_dragOverBlock is not null && IsSameBlock(_dragOverBlock, block))
178-
{
179-
_dragOverBlock = null;
180-
}
181-
}
182-
183-
private async Task OnBlockDropAsync(EditorRenderedBlockViewModel target)
197+
[JSInvokable]
198+
public async Task HandleRenderedCardDropAsync(
199+
int sourceSegmentIndex,
200+
int sourceBlockIndex,
201+
int targetSegmentIndex,
202+
int targetBlockIndex)
184203
{
185-
var dragged = _draggedBlock;
186-
_draggedBlock = null;
187-
_dragOverBlock = null;
188-
if (dragged is null || IsSameBlock(dragged, target))
204+
var blocks = FlattenBlocks();
205+
var dragged = blocks.FirstOrDefault(block =>
206+
block.SegmentIndex == sourceSegmentIndex &&
207+
block.BlockIndex == sourceBlockIndex);
208+
var target = blocks.FirstOrDefault(block =>
209+
block.SegmentIndex == targetSegmentIndex &&
210+
block.BlockIndex == targetBlockIndex);
211+
if (dragged is null || target is null || IsSameBlock(dragged, target))
189212
{
190213
return;
191214
}
192215

193-
await BlockReorderRequested.InvokeAsync(new EditorRenderedBlockReorderRequest(
194-
dragged.SegmentIndex,
195-
dragged.BlockIndex,
196-
target.SegmentIndex,
197-
target.BlockIndex,
198-
IsBefore(dragged, target)));
199-
}
200-
201-
private void OnBlockDragEnd()
202-
{
203-
_draggedBlock = null;
204-
_dragOverBlock = null;
216+
await RequestBlockReorderAsync(dragged, target);
205217
}
206218

207219
private async Task MoveBlockByOffsetAsync(EditorRenderedBlockViewModel block, int offset)
@@ -231,22 +243,6 @@
231243
return index >= 0 && targetIndex >= 0 && targetIndex < blocks.Count;
232244
}
233245

234-
private string GetBlockCssClass(EditorRenderedBlockViewModel block)
235-
{
236-
var tokens = new List<string> { "editor-rendered-block" };
237-
if (_draggedBlock is not null && IsSameBlock(_draggedBlock, block))
238-
{
239-
tokens.Add("editor-rendered-block--dragging");
240-
}
241-
242-
if (_dragOverBlock is not null && IsSameBlock(_dragOverBlock, block))
243-
{
244-
tokens.Add("editor-rendered-block--drop-target");
245-
}
246-
247-
return string.Join(' ', tokens);
248-
}
249-
250246
private List<EditorRenderedBlockViewModel> FlattenBlocks() =>
251247
Segments.SelectMany(segment => segment.Blocks).ToList();
252248

@@ -257,5 +253,29 @@
257253
source.SegmentIndex < target.SegmentIndex ||
258254
(source.SegmentIndex == target.SegmentIndex && source.BlockIndex < target.BlockIndex);
259255

256+
private Task RequestBlockReorderAsync(EditorRenderedBlockViewModel dragged, EditorRenderedBlockViewModel target) =>
257+
BlockReorderRequested.InvokeAsync(new EditorRenderedBlockReorderRequest(
258+
dragged.SegmentIndex,
259+
dragged.BlockIndex,
260+
target.SegmentIndex,
261+
target.BlockIndex,
262+
IsBefore(dragged, target)));
263+
264+
public async ValueTask DisposeAsync()
265+
{
266+
if (_renderedCardsHandle is not null)
267+
{
268+
await _renderedCardsHandle.InvokeVoidAsync("dispose");
269+
await _renderedCardsHandle.DisposeAsync();
270+
}
271+
272+
if (_renderedCardsModule is not null)
273+
{
274+
await _renderedCardsModule.DisposeAsync();
275+
}
276+
277+
_dotNetReference?.Dispose();
278+
}
279+
260280
private string Text(UiTextKey key) => Localizer[key.ToString()];
261281
}

0 commit comments

Comments
 (0)