Skip to content

Commit 7e16c8e

Browse files
authored
Merge pull request #48 from linkdotnet/feature/enhance-editor
Feature/enhance editor
2 parents 1fa4356 + 5a8f801 commit 7e16c8e

File tree

7 files changed

+186
-5
lines changed

7 files changed

+186
-5
lines changed

src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
<script async src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.3.1/languages/csharp.min.js" integrity="sha512-v7mtZg9ySysViDE/8FxpWzLPe4Qzj+xQ//OqdMkl0UapomXAjp79QNiziv6PLmG5GSXjTcfCOzEBv5B/Rp6COg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
4949
<script async src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/js/all.min.js" integrity="sha512-Tn2m0TIpgVyTzzvmxLNuqbSJH3JP8jm+Cy3hvHrW7ndTDcJ1w5mBiksqDBb8GpE2ksktFvDB/ykZ0mDpsZj20w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
5050
<script async src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.min.js" integrity="sha256-cMPWkL3FzjuaFSfEYESYmjF25hCIL6mfRSPnW8OVvM4=" crossorigin="anonymous"></script>
51+
<script async src="components/selection.js" ></script>
5152
<script async src="components/slideshow.js" ></script>
5253
</body>
5354
</html>

src/LinkDotNet.Blog.Web/Shared/Admin/CreateNewBlogPost.razor

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@using LinkDotNet.Blog.Domain
2+
@using LinkDotNet.Blog.Web.Shared.Services
23
@inherits MarkdownComponentBase
34

45
<h3>@Title</h3>
@@ -15,14 +16,15 @@
1516
</div>
1617
<div class="mb-3">
1718
<label for="short">Short Description</label>
18-
<textarea class="form-control" id="short" rows="4"
19-
@oninput="args => model.ShortDescription = args.Value.ToString()">@model.ShortDescription</textarea>
20-
<small for="short" class="form-text text-muted">You can use markdown to style your component.</small>
19+
<TextAreaWithShortcuts Id="short" Class="form-control" Rows="4"
20+
@bind-Value="@model.ShortDescription"></TextAreaWithShortcuts>
21+
<small for="short" class="form-text text-muted">You can use markdown to style your component</small>
2122
</div>
2223
<div class="mb-3">
2324
<label for="content">Content</label>
24-
<textarea class="form-control" id="content" @oninput="args => model.Content = args.Value.ToString()" rows="10">@model.Content</textarea>
25-
<small for="content" class="form-text text-muted">You can use markdown to style your component. Additional features are listed <a @onclick="@(() => FeatureDialog.Open())">here</a></small>
25+
<TextAreaWithShortcuts Id="content" Class="form-control" Rows="10"
26+
@bind-Value="@model.Content"></TextAreaWithShortcuts>
27+
<small for="content" class="form-text text-muted">You can use markdown to style your component. Additional features and keyboard shortcuts are listed <a @onclick="@(() => FeatureDialog.Open())">here</a></small>
2628
<UploadFile OnFileUploaded="SetContentFromFile" id="content-upload"></UploadFile>
2729
<small for="content-upload" class="form-text text-muted">Drag and drop markdown files to upload and
2830
insert them</small>

src/LinkDotNet.Blog.Web/Shared/Admin/FeatureInfoDialog.razor

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,30 @@
33
<p>Features marked with <i class="fas fa-flask"></i> are experimental and can change heavily, get removed or the usage changes.</p>
44
<p>Use with caution and check the changelog</p>
55
<hr>
6+
<h3 style="display:inline-block">Shortcuts</h3>
7+
<table class="table">
8+
<thead>
9+
<tr>
10+
<th>Keyboard shortcut</th>
11+
<th>Description</th>
12+
</tr>
13+
</thead>
14+
<tbody>
15+
<tr>
16+
<td>control b</td>
17+
<td>Inserts Markdown formatting for bolding text</td>
18+
</tr>
19+
<tr>
20+
<td>control i</td>
21+
<td>Inserts Markdown formatting for italicizing text</td>
22+
</tr>
23+
<tr>
24+
<td>control m</td>
25+
<td>Inserts Markdown formatting for creating a link</td>
26+
</tr>
27+
</tbody>
28+
</table>
29+
<hr/>
630
<h3 style="display:inline-block">Slide-Show</h3><i class="fas fa-flask"></i>
731
<p>Will create a slide-show with images specified by the tag.</p>
832
<strong>Usage:</strong>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace LinkDotNet.Blog.Web.Shared;
2+
3+
public sealed class SelectionRange
4+
{
5+
public int Start { get; set; }
6+
7+
public int End { get; set; }
8+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
@inject IJSRuntime jsRuntime
2+
3+
<textarea class="@Class" id="@Id" rows="@Rows"
4+
@onkeyup="MarkShortDescription" @onkeyup:preventDefault="true" @onabort:stopPropagation="true"
5+
@oninput="args => Value = args.Value.ToString()">@Value</textarea>
6+
7+
@code {
8+
private string textContent = string.Empty;
9+
private const string SelectedMarker = "#selection#";
10+
private const string CursorMarker = "#cursor#";
11+
12+
[Parameter]
13+
public string Value
14+
{
15+
get => textContent;
16+
set
17+
{
18+
if (textContent != value)
19+
{
20+
textContent = value;
21+
ValueChanged.InvokeAsync(value);
22+
}
23+
}
24+
}
25+
26+
[Parameter]
27+
public EventCallback<string> ValueChanged { get; set; }
28+
29+
[Parameter]
30+
public string Class { get; set; }
31+
32+
[Parameter]
33+
public string Id { get; set; }
34+
35+
[Parameter]
36+
public int Rows { get; set; }
37+
38+
private async Task MarkShortDescription(KeyboardEventArgs keyboardEventArgs)
39+
{
40+
Value = await GetNewMarkdownForElementAsync(keyboardEventArgs, Value, Id);
41+
StateHasChanged();
42+
}
43+
44+
private async Task<string> GetNewMarkdownForElementAsync(
45+
KeyboardEventArgs keyboardEventArgs,
46+
string original,
47+
string elementId)
48+
{
49+
return keyboardEventArgs.CtrlKey ? keyboardEventArgs.Key switch
50+
{
51+
"b" => await GetNewMarkdownForElementAsync(elementId, original, $"**{SelectedMarker}**{CursorMarker}"),
52+
"i" => await GetNewMarkdownForElementAsync(elementId, original, $"*{SelectedMarker}*{CursorMarker}"),
53+
"m" => await GetNewMarkdownForElementAsync(elementId, original, $"[{SelectedMarker}]({CursorMarker})"),
54+
_ => original,
55+
} : original;
56+
}
57+
58+
private async Task<string> GetNewMarkdownForElementAsync(
59+
string elementId,
60+
string content,
61+
string fence)
62+
{
63+
var selectionRange = await jsRuntime.InvokeAsync<SelectionRange>("getSelectionFromElement", elementId);
64+
if (selectionRange.Start == selectionRange.End)
65+
{
66+
return content;
67+
}
68+
69+
var beforeMarker = selectionRange.Start > 0 ? content[..selectionRange.Start] : string.Empty;
70+
var selectedContent = content[selectionRange.Start..selectionRange.End];
71+
var fencedContent = fence.Replace(SelectedMarker, selectedContent);
72+
var afterMarker = content[selectionRange.End..];
73+
var shift = selectionRange.Start + fencedContent.IndexOf(CursorMarker, StringComparison.Ordinal);
74+
var removedCursor = fencedContent.Replace(CursorMarker, string.Empty);
75+
await jsRuntime.InvokeVoidAsync("setSelectionFromElement", elementId, shift);
76+
return beforeMarker + removedCursor + afterMarker;
77+
}
78+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
window.getSelectionFromElement = function (id) {
2+
const elem = document.getElementById(id)
3+
const start = elem.selectionStart
4+
const end = elem.selectionEnd
5+
return { start, end }
6+
}
7+
8+
window.setSelectionFromElement = function (id, cursor) {
9+
const elem = document.getElementById(id)
10+
elem.selectionStart = cursor
11+
elem.selectionEnd = cursor
12+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Linq;
2+
using Bunit;
3+
using FluentAssertions;
4+
using LinkDotNet.Blog.Web.Shared;
5+
using Xunit;
6+
7+
namespace LinkDotNet.Blog.UnitTests.Web.Shared;
8+
9+
public class TextAreaWithShortcutsTests : TestContext
10+
{
11+
[Theory]
12+
[InlineData("b", 0, 4, true, "**Test**")]
13+
[InlineData("i", 0, 4, true, "*Test*")]
14+
[InlineData("m", 0, 4, true, "[Test]()")]
15+
[InlineData("h", 0, 1, true, "Test")]
16+
[InlineData("b", 0, 1, false, "Test")]
17+
[InlineData("f", 0, 4, false, "Test")]
18+
[InlineData("b", 0, 0, true, "Test")]
19+
public void ShouldSetMarkerOnKeyUp(string key, int start, int end, bool ctrlPressed, string expected)
20+
{
21+
const string id = "id";
22+
var range = new SelectionRange
23+
{
24+
Start = start,
25+
End = end,
26+
};
27+
JSInterop.Mode = JSRuntimeMode.Loose;
28+
JSInterop.Setup<SelectionRange>("getSelectionFromElement", id).SetResult(range);
29+
var cut = RenderComponent<TextAreaWithShortcuts>(
30+
p => p.Add(s => s.Id, id));
31+
cut.Find("textarea").Input("Test");
32+
cut.Find("textarea").KeyUp(key, ctrlKey: ctrlPressed);
33+
34+
var content = cut.Find("textarea").TextContent;
35+
36+
content.Should().Be(expected);
37+
}
38+
39+
[Fact]
40+
public void ShouldSetCursorPosition()
41+
{
42+
const string element = "id";
43+
JSInterop.Mode = JSRuntimeMode.Loose;
44+
JSInterop.Setup<SelectionRange>("getSelectionFromElement", element)
45+
.SetResult(new SelectionRange { Start = 2, End = 5 });
46+
var cut = RenderComponent<TextAreaWithShortcuts>(
47+
p => p.Add(s => s.Id, element));
48+
cut.Find($"#{element}").Input("Hello World");
49+
50+
cut.Find($"#{element}").KeyUp("b", ctrlKey: true);
51+
52+
var setSelection = JSInterop.Invocations.SingleOrDefault(s => s.Identifier == "setSelectionFromElement");
53+
setSelection.Arguments.Should().Contain(element);
54+
setSelection.Arguments.Should().Contain(9);
55+
}
56+
}

0 commit comments

Comments
 (0)