diff --git a/src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml b/src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml index 59a033ab..a6eb706e 100644 --- a/src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml +++ b/src/LinkDotNet.Blog.Web/Pages/_Layout.cshtml @@ -48,6 +48,7 @@ + \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Web/Shared/Admin/CreateNewBlogPost.razor b/src/LinkDotNet.Blog.Web/Shared/Admin/CreateNewBlogPost.razor index 6d21b6ec..8a1dbfad 100644 --- a/src/LinkDotNet.Blog.Web/Shared/Admin/CreateNewBlogPost.razor +++ b/src/LinkDotNet.Blog.Web/Shared/Admin/CreateNewBlogPost.razor @@ -1,4 +1,5 @@ @using LinkDotNet.Blog.Domain +@using LinkDotNet.Blog.Web.Shared.Services @inherits MarkdownComponentBase

@Title

@@ -15,14 +16,15 @@
- - You can use markdown to style your component. + + You can use markdown to style your component
- - You can use markdown to style your component. Additional features are listed here + + You can use markdown to style your component. Additional features and keyboard shortcuts are listed here Drag and drop markdown files to upload and insert them diff --git a/src/LinkDotNet.Blog.Web/Shared/Admin/FeatureInfoDialog.razor b/src/LinkDotNet.Blog.Web/Shared/Admin/FeatureInfoDialog.razor index f4bbf7fd..4e9b83f9 100644 --- a/src/LinkDotNet.Blog.Web/Shared/Admin/FeatureInfoDialog.razor +++ b/src/LinkDotNet.Blog.Web/Shared/Admin/FeatureInfoDialog.razor @@ -3,6 +3,30 @@

Features marked with are experimental and can change heavily, get removed or the usage changes.

Use with caution and check the changelog


+

Shortcuts

+ + + + + + + + + + + + + + + + + + + + + +
Keyboard shortcutDescription
control bInserts Markdown formatting for bolding text
control iInserts Markdown formatting for italicizing text
control mInserts Markdown formatting for creating a link
+

Slide-Show

Will create a slide-show with images specified by the tag.

Usage: diff --git a/src/LinkDotNet.Blog.Web/Shared/SelectionRange.cs b/src/LinkDotNet.Blog.Web/Shared/SelectionRange.cs new file mode 100644 index 00000000..829010c4 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Shared/SelectionRange.cs @@ -0,0 +1,8 @@ +namespace LinkDotNet.Blog.Web.Shared; + +public sealed class SelectionRange +{ + public int Start { get; set; } + + public int End { get; set; } +} \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Web/Shared/TextAreaWithShortcuts.razor b/src/LinkDotNet.Blog.Web/Shared/TextAreaWithShortcuts.razor new file mode 100644 index 00000000..57f44bef --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Shared/TextAreaWithShortcuts.razor @@ -0,0 +1,78 @@ +@inject IJSRuntime jsRuntime + + + +@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 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 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 GetNewMarkdownForElementAsync( + string elementId, + string content, + string fence) + { + var selectionRange = await jsRuntime.InvokeAsync("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; + } +} \ No newline at end of file diff --git a/src/LinkDotNet.Blog.Web/wwwroot/components/selection.js b/src/LinkDotNet.Blog.Web/wwwroot/components/selection.js new file mode 100644 index 00000000..d8218ce0 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/wwwroot/components/selection.js @@ -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 +} \ No newline at end of file diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Shared/TextAreaWithShortcutsTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Shared/TextAreaWithShortcutsTests.cs new file mode 100644 index 00000000..8742ae1e --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Shared/TextAreaWithShortcutsTests.cs @@ -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("getSelectionFromElement", id).SetResult(range); + var cut = RenderComponent( + 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("getSelectionFromElement", element) + .SetResult(new SelectionRange { Start = 2, End = 5 }); + var cut = RenderComponent( + 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); + } +} \ No newline at end of file