Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
<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>
<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>
<script async src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.min.js" integrity="sha256-cMPWkL3FzjuaFSfEYESYmjF25hCIL6mfRSPnW8OVvM4=" crossorigin="anonymous"></script>
<script async src="components/selection.js" ></script>
<script async src="components/slideshow.js" ></script>
</body>
</html>
12 changes: 7 additions & 5 deletions src/LinkDotNet.Blog.Web/Shared/Admin/CreateNewBlogPost.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Web.Shared.Services
@inherits MarkdownComponentBase

<h3>@Title</h3>
Expand All @@ -15,14 +16,15 @@
</div>
<div class="mb-3">
<label for="short">Short Description</label>
<textarea class="form-control" id="short" rows="4"
@oninput="args => model.ShortDescription = args.Value.ToString()">@model.ShortDescription</textarea>
<small for="short" class="form-text text-muted">You can use markdown to style your component.</small>
<TextAreaWithShortcuts Id="short" Class="form-control" Rows="4"
@bind-Value="@model.ShortDescription"></TextAreaWithShortcuts>
<small for="short" class="form-text text-muted">You can use markdown to style your component</small>
</div>
<div class="mb-3">
<label for="content">Content</label>
<textarea class="form-control" id="content" @oninput="args => model.Content = args.Value.ToString()" rows="10">@model.Content</textarea>
<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>
<TextAreaWithShortcuts Id="content" Class="form-control" Rows="10"
@bind-Value="@model.Content"></TextAreaWithShortcuts>
<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>
<UploadFile OnFileUploaded="SetContentFromFile" id="content-upload"></UploadFile>
<small for="content-upload" class="form-text text-muted">Drag and drop markdown files to upload and
insert them</small>
Expand Down
24 changes: 24 additions & 0 deletions src/LinkDotNet.Blog.Web/Shared/Admin/FeatureInfoDialog.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,30 @@
<p>Features marked with <i class="fas fa-flask"></i> are experimental and can change heavily, get removed or the usage changes.</p>
<p>Use with caution and check the changelog</p>
<hr>
<h3 style="display:inline-block">Shortcuts</h3>
<table class="table">
<thead>
<tr>
<th>Keyboard shortcut</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>control b</td>
<td>Inserts Markdown formatting for bolding text</td>
</tr>
<tr>
<td>control i</td>
<td>Inserts Markdown formatting for italicizing text</td>
</tr>
<tr>
<td>control m</td>
<td>Inserts Markdown formatting for creating a link</td>
</tr>
</tbody>
</table>
<hr/>
<h3 style="display:inline-block">Slide-Show</h3><i class="fas fa-flask"></i>
<p>Will create a slide-show with images specified by the tag.</p>
<strong>Usage:</strong>
Expand Down
8 changes: 8 additions & 0 deletions src/LinkDotNet.Blog.Web/Shared/SelectionRange.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace LinkDotNet.Blog.Web.Shared;

public sealed class SelectionRange
{
public int Start { get; set; }

public int End { get; set; }
}
78 changes: 78 additions & 0 deletions src/LinkDotNet.Blog.Web/Shared/TextAreaWithShortcuts.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
@inject IJSRuntime jsRuntime

<textarea class="@Class" id="@Id" rows="@Rows"
@onkeyup="MarkShortDescription" @onkeyup:preventDefault="true" @onabort:stopPropagation="true"
@oninput="args => Value = args.Value.ToString()">@Value</textarea>

@code {
private string textContent = string.Empty;
private const string SelectedMarker = "#selection#";
private const string CursorMarker = "#cursor#";

[Parameter]
public string Value
{
get => textContent;
set
{
if (textContent != value)
{
textContent = value;
ValueChanged.InvokeAsync(value);
}
}
}

[Parameter]
public EventCallback<string> ValueChanged { get; set; }

[Parameter]
public string Class { get; set; }

[Parameter]
public string Id { get; set; }

[Parameter]
public int Rows { get; set; }

private async Task MarkShortDescription(KeyboardEventArgs keyboardEventArgs)
{
Value = await GetNewMarkdownForElementAsync(keyboardEventArgs, Value, Id);
StateHasChanged();
}

private async Task<string> GetNewMarkdownForElementAsync(
KeyboardEventArgs keyboardEventArgs,
string original,
string elementId)
{
return keyboardEventArgs.CtrlKey ? keyboardEventArgs.Key switch
{
"b" => await GetNewMarkdownForElementAsync(elementId, original, $"**{SelectedMarker}**{CursorMarker}"),
"i" => await GetNewMarkdownForElementAsync(elementId, original, $"*{SelectedMarker}*{CursorMarker}"),
"m" => await GetNewMarkdownForElementAsync(elementId, original, $"[{SelectedMarker}]({CursorMarker})"),
_ => original,
} : original;
}

private async Task<string> GetNewMarkdownForElementAsync(
string elementId,
string content,
string fence)
{
var selectionRange = await jsRuntime.InvokeAsync<SelectionRange>("getSelectionFromElement", elementId);
if (selectionRange.Start == selectionRange.End)
{
return content;
}

var beforeMarker = selectionRange.Start > 0 ? content[..selectionRange.Start] : string.Empty;
var selectedContent = content[selectionRange.Start..selectionRange.End];
var fencedContent = fence.Replace(SelectedMarker, selectedContent);
var afterMarker = content[selectionRange.End..];
var shift = selectionRange.Start + fencedContent.IndexOf(CursorMarker, StringComparison.Ordinal);
var removedCursor = fencedContent.Replace(CursorMarker, string.Empty);
await jsRuntime.InvokeVoidAsync("setSelectionFromElement", elementId, shift);
return beforeMarker + removedCursor + afterMarker;
}
}
12 changes: 12 additions & 0 deletions src/LinkDotNet.Blog.Web/wwwroot/components/selection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
window.getSelectionFromElement = function (id) {
const elem = document.getElementById(id)
const start = elem.selectionStart
const end = elem.selectionEnd
return { start, end }
}

window.setSelectionFromElement = function (id, cursor) {
const elem = document.getElementById(id)
elem.selectionStart = cursor
elem.selectionEnd = cursor
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Linq;
using Bunit;
using FluentAssertions;
using LinkDotNet.Blog.Web.Shared;
using Xunit;

namespace LinkDotNet.Blog.UnitTests.Web.Shared;

public class TextAreaWithShortcutsTests : TestContext
{
[Theory]
[InlineData("b", 0, 4, true, "**Test**")]
[InlineData("i", 0, 4, true, "*Test*")]
[InlineData("m", 0, 4, true, "[Test]()")]
[InlineData("h", 0, 1, true, "Test")]
[InlineData("b", 0, 1, false, "Test")]
[InlineData("f", 0, 4, false, "Test")]
[InlineData("b", 0, 0, true, "Test")]
public void ShouldSetMarkerOnKeyUp(string key, int start, int end, bool ctrlPressed, string expected)
{
const string id = "id";
var range = new SelectionRange
{
Start = start,
End = end,
};
JSInterop.Mode = JSRuntimeMode.Loose;
JSInterop.Setup<SelectionRange>("getSelectionFromElement", id).SetResult(range);
var cut = RenderComponent<TextAreaWithShortcuts>(
p => p.Add(s => s.Id, id));
cut.Find("textarea").Input("Test");
cut.Find("textarea").KeyUp(key, ctrlKey: ctrlPressed);

var content = cut.Find("textarea").TextContent;

content.Should().Be(expected);
}

[Fact]
public void ShouldSetCursorPosition()
{
const string element = "id";
JSInterop.Mode = JSRuntimeMode.Loose;
JSInterop.Setup<SelectionRange>("getSelectionFromElement", element)
.SetResult(new SelectionRange { Start = 2, End = 5 });
var cut = RenderComponent<TextAreaWithShortcuts>(
p => p.Add(s => s.Id, element));
cut.Find($"#{element}").Input("Hello World");

cut.Find($"#{element}").KeyUp("b", ctrlKey: true);

var setSelection = JSInterop.Invocations.SingleOrDefault(s => s.Identifier == "setSelectionFromElement");
setSelection.Arguments.Should().Contain(element);
setSelection.Arguments.Should().Contain(9);
}
}