Skip to content

Commit c95d95c

Browse files
committed
Add rendered block drag reorder (#47)
1 parent 90b85a1 commit c95d95c

17 files changed

Lines changed: 511 additions & 1 deletion

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

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

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

194+
public static string RenderedBlockMoveDown(int segmentIndex, int blockIndex) => $"editor-rendered-block-move-down-{segmentIndex}-{blockIndex}";
195+
196+
public static string RenderedBlockMoveUp(int segmentIndex, int blockIndex) => $"editor-rendered-block-move-up-{segmentIndex}-{blockIndex}";
197+
194198
public static string RenderedBlockText(int segmentIndex, int blockIndex) => $"editor-rendered-block-text-{segmentIndex}-{blockIndex}";
195199

196200
public static string RenderedSegment(int segmentIndex) => $"editor-rendered-segment-{segmentIndex}";

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

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
@namespace PrompterOne.Shared.Components.Editor
2+
@using Microsoft.Extensions.Localization
3+
@using PrompterOne.Shared.Localization
4+
@inject IStringLocalizer<SharedResource> Localizer
25

36
<div class="editor-rendered-view" data-test="@UiTestIds.Editor.RenderedView">
47
<div class="editor-rendered-strip" data-test="@UiTestIds.Editor.RenderedStrip">
@@ -33,10 +36,35 @@
3336

3437
@foreach (var block in segment.Blocks)
3538
{
36-
<article class="editor-rendered-block" data-test="@UiTestIds.Editor.RenderedBlock(block.SegmentIndex, block.BlockIndex)">
39+
<article class="@GetBlockCssClass(block)"
40+
draggable="true"
41+
@ondragstart="() => OnBlockDragStart(block)"
42+
@ondragend="OnBlockDragEnd"
43+
@ondragenter="() => OnBlockDragEnter(block)"
44+
@ondragleave="() => OnBlockDragLeave(block)"
45+
@ondragover:preventDefault="true"
46+
@ondrop="() => OnBlockDropAsync(block)"
47+
@ondrop:preventDefault="true"
48+
data-test="@UiTestIds.Editor.RenderedBlock(block.SegmentIndex, block.BlockIndex)">
3749
<div class="editor-rendered-block-head">
50+
<span class="editor-rendered-block-drag-handle"
51+
aria-hidden="true">⋮⋮</span>
3852
<span class="editor-rendered-block-number">@block.Number</span>
3953
<span class="editor-rendered-block-title">@block.Name</span>
54+
<span class="editor-rendered-block-actions">
55+
<button type="button"
56+
class="editor-rendered-block-move"
57+
aria-label="@Text(UiTextKey.EditorRenderedMoveBlockUp)"
58+
disabled="@(!CanMoveBlock(block, -1))"
59+
@onclick="() => MoveBlockByOffsetAsync(block, -1)"
60+
data-test="@UiTestIds.Editor.RenderedBlockMoveUp(block.SegmentIndex, block.BlockIndex)">↑</button>
61+
<button type="button"
62+
class="editor-rendered-block-move"
63+
aria-label="@Text(UiTextKey.EditorRenderedMoveBlockDown)"
64+
disabled="@(!CanMoveBlock(block, 1))"
65+
@onclick="() => MoveBlockByOffsetAsync(block, 1)"
66+
data-test="@UiTestIds.Editor.RenderedBlockMoveDown(block.SegmentIndex, block.BlockIndex)">↓</button>
67+
</span>
4068
</div>
4169
<textarea class="editor-rendered-text"
4270
aria-label="@block.Name"
@@ -67,6 +95,12 @@
6795

6896
[Parameter] public EventCallback<EditorRenderedBlockTextChange> TextChanged { get; set; }
6997

98+
[Parameter] public EventCallback<EditorRenderedBlockReorderRequest> BlockReorderRequested { get; set; }
99+
100+
private EditorRenderedBlockViewModel? _draggedBlock;
101+
102+
private EditorRenderedBlockViewModel? _dragOverBlock;
103+
70104
private Task OnBlockInputAsync(EditorRenderedBlockViewModel block, ChangeEventArgs args) =>
71105
TextChanged.InvokeAsync(new EditorRenderedBlockTextChange(
72106
block.SegmentIndex,
@@ -78,4 +112,105 @@
78112
0,
79113
0,
80114
args.Value?.ToString() ?? string.Empty));
115+
116+
private void OnBlockDragStart(EditorRenderedBlockViewModel block)
117+
{
118+
_draggedBlock = block;
119+
_dragOverBlock = null;
120+
}
121+
122+
private void OnBlockDragEnter(EditorRenderedBlockViewModel block)
123+
{
124+
if (_draggedBlock is not null && !IsSameBlock(_draggedBlock, block))
125+
{
126+
_dragOverBlock = block;
127+
}
128+
}
129+
130+
private void OnBlockDragLeave(EditorRenderedBlockViewModel block)
131+
{
132+
if (_dragOverBlock is not null && IsSameBlock(_dragOverBlock, block))
133+
{
134+
_dragOverBlock = null;
135+
}
136+
}
137+
138+
private async Task OnBlockDropAsync(EditorRenderedBlockViewModel target)
139+
{
140+
var dragged = _draggedBlock;
141+
_draggedBlock = null;
142+
_dragOverBlock = null;
143+
if (dragged is null || IsSameBlock(dragged, target))
144+
{
145+
return;
146+
}
147+
148+
await BlockReorderRequested.InvokeAsync(new EditorRenderedBlockReorderRequest(
149+
dragged.SegmentIndex,
150+
dragged.BlockIndex,
151+
target.SegmentIndex,
152+
target.BlockIndex,
153+
IsBefore(dragged, target)));
154+
}
155+
156+
private void OnBlockDragEnd()
157+
{
158+
_draggedBlock = null;
159+
_dragOverBlock = null;
160+
}
161+
162+
private async Task MoveBlockByOffsetAsync(EditorRenderedBlockViewModel block, int offset)
163+
{
164+
var blocks = FlattenBlocks();
165+
var index = blocks.FindIndex(candidate => IsSameBlock(candidate, block));
166+
var targetIndex = index + offset;
167+
if (index < 0 || targetIndex < 0 || targetIndex >= blocks.Count)
168+
{
169+
return;
170+
}
171+
172+
var target = blocks[targetIndex];
173+
await BlockReorderRequested.InvokeAsync(new EditorRenderedBlockReorderRequest(
174+
block.SegmentIndex,
175+
block.BlockIndex,
176+
target.SegmentIndex,
177+
target.BlockIndex,
178+
offset > 0));
179+
}
180+
181+
private bool CanMoveBlock(EditorRenderedBlockViewModel block, int offset)
182+
{
183+
var blocks = FlattenBlocks();
184+
var index = blocks.FindIndex(candidate => IsSameBlock(candidate, block));
185+
var targetIndex = index + offset;
186+
return index >= 0 && targetIndex >= 0 && targetIndex < blocks.Count;
187+
}
188+
189+
private string GetBlockCssClass(EditorRenderedBlockViewModel block)
190+
{
191+
var tokens = new List<string> { "editor-rendered-block" };
192+
if (_draggedBlock is not null && IsSameBlock(_draggedBlock, block))
193+
{
194+
tokens.Add("editor-rendered-block--dragging");
195+
}
196+
197+
if (_dragOverBlock is not null && IsSameBlock(_dragOverBlock, block))
198+
{
199+
tokens.Add("editor-rendered-block--drop-target");
200+
}
201+
202+
return string.Join(' ', tokens);
203+
}
204+
205+
private List<EditorRenderedBlockViewModel> FlattenBlocks() =>
206+
Segments.SelectMany(segment => segment.Blocks).ToList();
207+
208+
private static bool IsSameBlock(EditorRenderedBlockViewModel left, EditorRenderedBlockViewModel right) =>
209+
left.SegmentIndex == right.SegmentIndex && left.BlockIndex == right.BlockIndex;
210+
211+
private static bool IsBefore(EditorRenderedBlockViewModel source, EditorRenderedBlockViewModel target) =>
212+
source.SegmentIndex < target.SegmentIndex ||
213+
(source.SegmentIndex == target.SegmentIndex && source.BlockIndex < target.BlockIndex);
214+
215+
private string Text(UiTextKey key) => Localizer[key.ToString()];
81216
}

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@
8686
flex-direction: column;
8787
gap: 10px;
8888
min-width: 0;
89+
padding-block: 6px;
90+
border-radius: 6px;
91+
transition:
92+
background-color .16s ease,
93+
box-shadow .16s ease,
94+
opacity .16s ease;
95+
}
96+
97+
.editor-rendered-block--dragging {
98+
opacity: .62;
99+
}
100+
101+
.editor-rendered-block--drop-target {
102+
background: rgba(56, 112, 214, .08);
103+
box-shadow: inset 3px 0 0 rgba(56, 112, 214, .42);
89104
}
90105

91106
.editor-rendered-block-head {
@@ -95,6 +110,51 @@
95110
min-width: 0;
96111
}
97112

113+
.editor-rendered-block-drag-handle {
114+
flex: 0 0 auto;
115+
color: #a1aaba;
116+
cursor: grab;
117+
font-size: 13px;
118+
font-weight: 800;
119+
letter-spacing: 0;
120+
line-height: 1;
121+
user-select: none;
122+
}
123+
124+
.editor-rendered-block-actions {
125+
display: inline-flex;
126+
flex: 0 0 auto;
127+
gap: 4px;
128+
margin-inline-start: auto;
129+
}
130+
131+
.editor-rendered-block-move {
132+
display: inline-grid;
133+
width: 24px;
134+
height: 24px;
135+
place-items: center;
136+
border: 1px solid rgba(137, 148, 169, .34);
137+
border-radius: 6px;
138+
background: rgba(255, 255, 255, .78);
139+
color: #4c5a70;
140+
cursor: pointer;
141+
font-size: 13px;
142+
font-weight: 800;
143+
line-height: 1;
144+
}
145+
146+
.editor-rendered-block-move:disabled {
147+
cursor: default;
148+
opacity: .38;
149+
}
150+
151+
.editor-rendered-block-move:not(:disabled):hover,
152+
.editor-rendered-block-move:not(:disabled):focus-visible {
153+
border-color: rgba(56, 112, 214, .48);
154+
color: #244f9f;
155+
outline: none;
156+
}
157+
98158
.editor-rendered-block-title {
99159
min-width: 0;
100160
overflow: hidden;

src/PrompterOne.Shared/Editor/Components/EditorViewModels.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ public sealed record EditorRenderedBlockTextChange(
6868
int BlockIndex,
6969
string Text);
7070

71+
public sealed record EditorRenderedBlockReorderRequest(
72+
int SourceSegmentIndex,
73+
int SourceBlockIndex,
74+
int TargetSegmentIndex,
75+
int TargetBlockIndex,
76+
bool InsertAfterTarget);
77+
7178
public sealed record EditorNavigationTarget(
7279
int SegmentIndex,
7380
int? BlockIndex,

src/PrompterOne.Shared/Editor/Pages/EditorPage.RenderedText.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using PrompterOne.Core.Models.CompiledScript;
44
using PrompterOne.Core.Models.Editor;
55
using PrompterOne.Shared.Components.Editor;
6+
using PrompterOne.Shared.Services.Editor;
67

78
namespace PrompterOne.Shared.Pages;
89

@@ -75,6 +76,17 @@ private async Task OnRenderedTextChangedAsync(EditorRenderedBlockTextChange chan
7576
await ApplyMutationAsync(nextText, new EditorSelectionRange(caret, caret));
7677
}
7778

79+
private async Task OnRenderedBlockReorderRequestedAsync(EditorRenderedBlockReorderRequest request)
80+
{
81+
var result = EditorRenderedBlockReorderService.Reorder(_sourceText, _segments, request);
82+
if (result is null)
83+
{
84+
return;
85+
}
86+
87+
await ApplyMutationAsync(result.Text, result.Selection);
88+
}
89+
7890
private EditorOutlineBlockViewModel? FindRenderedBlock(EditorRenderedBlockTextChange change) =>
7991
_segments
8092
.FirstOrDefault(segment => segment.Index == change.SegmentIndex)

src/PrompterOne.Shared/Editor/Pages/EditorPage.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
<EditorRenderedTextView Segments="@BuildRenderedSegments()"
7272
FallbackText="@BuildRenderedFallbackText()"
7373
FallbackLabel="@Text(UiTextKey.EditorWorkspaceRenderedTab)"
74+
BlockReorderRequested="OnRenderedBlockReorderRequestedAsync"
7475
TextChanged="OnRenderedTextChangedAsync" />
7576
}
7677
else
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using PrompterOne.Core.Models.Editor;
2+
using PrompterOne.Shared.Components.Editor;
3+
4+
namespace PrompterOne.Shared.Services.Editor;
5+
6+
public sealed record EditorRenderedBlockReorderResult(string Text, EditorSelectionRange Selection);
7+
8+
public static class EditorRenderedBlockReorderService
9+
{
10+
public static EditorRenderedBlockReorderResult? Reorder(
11+
string source,
12+
IReadOnlyList<EditorOutlineSegmentViewModel> segments,
13+
EditorRenderedBlockReorderRequest request)
14+
{
15+
var blocks = FlattenBlocks(segments);
16+
var sourceBlock = blocks.FirstOrDefault(block =>
17+
block.SegmentIndex == request.SourceSegmentIndex &&
18+
block.BlockIndex == request.SourceBlockIndex);
19+
var targetBlock = blocks.FirstOrDefault(block =>
20+
block.SegmentIndex == request.TargetSegmentIndex &&
21+
block.BlockIndex == request.TargetBlockIndex);
22+
23+
if (sourceBlock is null || targetBlock is null || sourceBlock.Order == targetBlock.Order)
24+
{
25+
return null;
26+
}
27+
28+
var sourceRange = ResolveExclusiveRange(source, sourceBlock);
29+
var targetRange = ResolveExclusiveRange(source, targetBlock);
30+
if (sourceRange.Start == sourceRange.End || targetRange.Start == targetRange.End)
31+
{
32+
return null;
33+
}
34+
35+
var movingText = source[sourceRange.Start..sourceRange.End];
36+
var remainingText = source.Remove(sourceRange.Start, sourceRange.End - sourceRange.Start);
37+
var rawInsertionIndex = request.InsertAfterTarget ? targetRange.End : targetRange.Start;
38+
var insertionIndex = rawInsertionIndex > sourceRange.Start
39+
? rawInsertionIndex - movingText.Length
40+
: rawInsertionIndex;
41+
insertionIndex = Math.Clamp(insertionIndex, 0, remainingText.Length);
42+
43+
var nextText = remainingText.Insert(insertionIndex, movingText);
44+
var selection = new EditorSelectionRange(insertionIndex, insertionIndex);
45+
return string.Equals(source, nextText, StringComparison.Ordinal)
46+
? null
47+
: new EditorRenderedBlockReorderResult(nextText, selection);
48+
}
49+
50+
private static IReadOnlyList<RenderedBlockRange> FlattenBlocks(
51+
IReadOnlyList<EditorOutlineSegmentViewModel> segments) =>
52+
segments
53+
.SelectMany(segment => segment.Blocks.Select(block => new RenderedBlockRange(
54+
segment.Index,
55+
block.Index,
56+
block.StartIndex,
57+
block.EndIndex)))
58+
.OrderBy(block => block.StartIndex)
59+
.Select((block, order) => block with { Order = order })
60+
.ToList();
61+
62+
private static (int Start, int End) ResolveExclusiveRange(string source, RenderedBlockRange block)
63+
{
64+
if (string.IsNullOrEmpty(source))
65+
{
66+
return (0, 0);
67+
}
68+
69+
var start = Math.Clamp(block.StartIndex, 0, source.Length);
70+
var end = Math.Clamp(block.EndIndex + 1, start, source.Length);
71+
return (start, end);
72+
}
73+
74+
private sealed record RenderedBlockRange(
75+
int SegmentIndex,
76+
int BlockIndex,
77+
int StartIndex,
78+
int EndIndex)
79+
{
80+
public int Order { get; init; }
81+
}
82+
}

src/PrompterOne.Shared/Localization/SharedResource.de.resx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1629,6 +1629,12 @@
16291629
<data name="EditorWorkspaceGraphTab" xml:space="preserve">
16301630
<value>Graph</value>
16311631
</data>
1632+
<data name="EditorRenderedMoveBlockUp" xml:space="preserve">
1633+
<value>Block nach oben verschieben</value>
1634+
</data>
1635+
<data name="EditorRenderedMoveBlockDown" xml:space="preserve">
1636+
<value>Block nach unten verschieben</value>
1637+
</data>
16321638
<data name="HeaderOpenScript" xml:space="preserve">
16331639
<value>Import</value>
16341640
</data>

0 commit comments

Comments
 (0)