diff --git a/Directory.Packages.props b/Directory.Packages.props index 32229e54..07c66836 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,9 +25,6 @@ - - - diff --git a/src/LinkDotNet.Blog.Web/App.razor b/src/LinkDotNet.Blog.Web/App.razor index ea0d86e8..c824900a 100644 --- a/src/LinkDotNet.Blog.Web/App.razor +++ b/src/LinkDotNet.Blog.Web/App.razor @@ -24,11 +24,12 @@ + - + @@ -44,6 +45,7 @@ Reload x + diff --git a/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor.css b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor.css new file mode 100644 index 00000000..35654bcb --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/Admin/BlogPostEditor/Components/CreateNewBlogPost.razor.css @@ -0,0 +1,353 @@ +/* ── IDE Workspace ──────────────────────────────────────────────── */ +.ide-workspace { + overflow-x: hidden; +} + +/* ── Top Bar ────────────────────────────────────────────────────── */ +.ide-topbar { + position: sticky; + top: 0; + z-index: 10; + height: 44px; + background-color: var(--bs-secondary-bg); + border-bottom: 1px solid var(--bs-border-color); + display: flex; + align-items: center; + padding: 0 1rem; + gap: 0.5rem; +} + +.ide-breadcrumb { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + overflow: hidden; + min-width: 0; +} + +.ide-breadcrumb-sep { + font-size: 0.625rem; + opacity: 0.4; + flex-shrink: 0; +} + +.ide-breadcrumb-current { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 250px; + color: var(--bs-body-color); +} + +/* ── Status Pills ───────────────────────────────────────────────── */ +.ide-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.6875rem; + font-weight: 500; + padding: 0.2rem 0.625rem; + border-radius: 2rem; + border: 1px solid var(--bs-border-color); + background-color: var(--bs-body-bg); + color: var(--bs-secondary-color); + white-space: nowrap; + flex-shrink: 0; +} + +.ide-pill-dirty { + color: var(--bs-warning); + border-color: var(--bs-warning); + background-color: transparent; +} + +.ide-pill-publish { + color: var(--bs-success); + border-color: var(--bs-success); +} + +.ide-pill-editing { + color: var(--bs-info); + border-color: var(--bs-info); +} + +.ide-pill-scheduled { + color: var(--bs-warning); + border-color: var(--bs-warning); +} + +/* ── Main Two-Column Layout ─────────────────────────────────────── */ +.ide-layout { + display: flex; + align-items: flex-start; +} + +/* ── Editor Panel ───────────────────────────────────────────────── */ +.ide-editor-panel { + flex: 1; + min-width: 0; + border-right: 1px solid var(--bs-border-color); +} + +/* ── Title Section ──────────────────────────────────────────────── */ +.ide-title-section { + padding: 1.5rem 1.75rem 1.25rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.ide-title-input { + display: block; + width: 100%; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + font-size: 1.75rem; + font-weight: 700; + color: var(--bs-body-color); + padding: 0 0 0.25rem; + line-height: 1.2; + transition: border-color 0.15s ease; +} + +.ide-title-input:focus { + outline: none; + border-bottom-color: var(--bs-primary); +} + +.ide-title-input::placeholder { + color: var(--bs-secondary-color); + opacity: 0.35; + font-weight: 400; +} + +/* ── IDE Tab Bar ────────────────────────────────────────────────── */ +.ide-tabs { + display: flex; + background-color: var(--bs-secondary-bg); + border-bottom: 1px solid var(--bs-border-color); + overflow-x: auto; + scrollbar-width: none; +} + +.ide-tabs::-webkit-scrollbar { + display: none; +} + +.ide-tab { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1.25rem; + border: none; + border-right: 1px solid var(--bs-border-color); + border-bottom: 2px solid transparent; + background: transparent; + color: var(--bs-secondary-color); + font-size: 0.8125rem; + font-family: 'JetBrains Mono', 'Fira Code', 'Menlo', 'Courier New', monospace; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.1s ease, color 0.1s ease; +} + +.ide-tab:hover { + background-color: var(--bs-tertiary-bg); + color: var(--bs-body-color); +} + +.ide-tab-active { + background-color: var(--bs-body-bg) !important; + color: var(--bs-body-color) !important; + border-bottom-color: var(--bs-primary) !important; +} + +.ide-tab-icon { + opacity: 0.65; + font-size: 0.875rem; +} + +.ide-tab-badge { + font-size: 0.6875rem; + opacity: 0.5; + font-family: var(--bs-font-sans-serif); +} + +/* ── Editor Action Toolbar ──────────────────────────────────────── */ +.ide-editor-actions { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + background-color: var(--bs-secondary-bg); + border-bottom: 1px solid var(--bs-border-color); + flex-wrap: wrap; +} + +.ide-action-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + border: 1px solid transparent; + border-radius: 0.25rem; + background: transparent; + color: var(--bs-secondary-color); + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.1s ease; + white-space: nowrap; +} + +.ide-action-btn:hover { + background-color: var(--bs-tertiary-bg); + border-color: var(--bs-border-color); + color: var(--bs-body-color); +} + +/* ── MarkdownTextArea integration ───────────────────────────────── */ +::deep .markdown-editor-container.ide-editor { + border-radius: 0; + border-left: none; + border-right: none; + border-bottom: none; + box-shadow: none; +} + +::deep .markdown-editor-container.ide-editor:focus-within { + outline: none; +} + +/* ── Validation Errors ──────────────────────────────────────────── */ +.ide-error { + display: block; + font-size: 0.75rem; + color: var(--bs-danger); + padding-top: 0.25rem; +} + +/* ── Settings Sidebar ───────────────────────────────────────────── */ +.ide-settings-sidebar { + width: 300px; + min-width: 300px; + max-width: 300px; + background-color: var(--bs-secondary-bg); + border-left: 1px solid var(--bs-border-color); +} + +.ide-settings-section { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--bs-border-color); +} + +.ide-settings-label { + display: block; + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--bs-secondary-color); + margin-bottom: 0.875rem; +} + +/* ── Tag Chips ──────────────────────────────────────────────────── */ +.ide-tag-chip { + display: inline-flex; + align-items: center; + font-size: 0.6875rem; + padding: 0.125rem 0.5rem; + border-radius: 2rem; + background-color: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + color: var(--bs-body-color); + margin: 0.125rem 0.125rem 0 0; +} + +/* ── Status Bar ─────────────────────────────────────────────────── */ +.ide-statusbar { + height: 28px; + background-color: var(--bs-primary); + color: rgba(255, 255, 255, 0.9); + font-size: 0.75rem; + display: flex; + align-items: center; +} + +.ide-statusbar-item { + display: inline-flex; + align-items: center; + height: 100%; + padding: 0 0.75rem; + opacity: 0.85; + cursor: default; + white-space: nowrap; + transition: background-color 0.1s, opacity 0.1s; +} + +.ide-statusbar-item:hover { + background-color: rgba(255, 255, 255, 0.15); + opacity: 1; +} + +.ide-statusbar-gap { + flex: 1; +} + +.ide-statusbar-save { + display: inline-flex; + align-items: center; + gap: 0.375rem; + height: calc(100% - 4px); + margin: 2px; + padding: 0 0.875rem; + border-radius: 0.25rem; + background-color: rgba(255, 255, 255, 0.2); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.1s; +} + +.ide-statusbar-save:hover:not(:disabled) { + background-color: rgba(255, 255, 255, 0.3); +} + +.ide-statusbar-save:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Desktop: sticky sidebar ────────────────────────────────────── */ +@media (min-width: 992px) { + .ide-settings-sidebar { + position: sticky; + top: 44px; + max-height: calc(100svh - 44px); + overflow-y: auto; + align-self: flex-start; + } +} + +/* ── Mobile ─────────────────────────────────────────────────────── */ +@media (max-width: 991.98px) { + .ide-editor-panel { + border-right: none; + } + + .ide-title-section { + padding: 1rem 1rem 0.75rem; + } + + .ide-title-input { + font-size: 1.375rem; + } + + .ide-statusbar { + position: sticky; + bottom: 0; + z-index: 10; + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/Components/MarkdownTextArea.razor b/src/LinkDotNet.Blog.Web/Features/Components/MarkdownTextArea.razor index 3e40b365..e6e3d31a 100644 --- a/src/LinkDotNet.Blog.Web/Features/Components/MarkdownTextArea.razor +++ b/src/LinkDotNet.Blog.Web/Features/Components/MarkdownTextArea.razor @@ -1,45 +1,169 @@ @using System.IO -@using Blazorise -@using Blazorise.Markdown +@using System.Linq @using LinkDotNet.Blog.Web.Features.Services.FileUpload @using IToastService = Blazored.Toast.Services.IToastService @inject IBlobUploadService BlobUploadService @inject IToastService ToastService +@inject IJSRuntime JSRuntime +@implements IAsyncDisposable - +
+
+ + +
+ + + + + +
+ + + + +
+ + +
+ + + +
+ + + +
+
+ + @if (showTablePicker) + { +
+
+
Insert Table
+ + + +
+ } +
+
+ +
+ +
+ @if (viewMode == ViewMode.Preview) + { +
+ @((MarkupString)previewHtml) +
+ } + else + { + + } +
+
+ + @code { - private string textContent = string.Empty; + private enum ViewMode { Editor, Preview } - private string Height => $"{Rows * 25}px"; + public sealed class UndoRedoState + { + public bool CanUndo { get; set; } + public bool CanRedo { get; set; } + } + private string Height => $"{Rows * 25}px"; + private ElementReference textAreaRef; + private InputFile inputFileRef = default!; private UploadFileModalDialog UploadDialog { get; set; } = default!; + private ViewMode viewMode = ViewMode.Editor; + private string previewHtml = string.Empty; + private DotNetObjectReference? dotNetHelper; + private bool isMac; + private bool canUndo; + private bool canRedo; + private bool showTablePicker; + private int tableCols = 2; + private int tableRows = 2; + private const long MaxFileSizeBytes = 512 * 1024; // 512 KB #pragma warning disable BL0007 [Parameter] public string Value { - get => textContent; + get; set { - if (textContent != value) + if (field != value) { - textContent = value; - ValueChanged.InvokeAsync(value); + field = value; + _ = InvokeAsync(async () => + { + await ValueChanged.InvokeAsync(value); + await UpdatePreview(); + }); } } - } + } = string.Empty; #pragma warning restore [Parameter] @@ -55,35 +179,237 @@ [Parameter] public string Placeholder { get; set; } = string.Empty; [Parameter] public Func> PreviewFunction { get; set; } = - s => Task.FromResult(MarkdownConverter.ToMarkupString(s).Value); - - private async Task UploadFiles(FileChangedEventArgs arg) - { - try - { - foreach (var file in arg.Files) - { - using var memoryStream = new MemoryStream(); - await file.WriteToStreamAsync(memoryStream); - memoryStream.Position = 0; - var options = await UploadDialog.ShowAsync(file.Name); - if (options is null) - { - await file.Cancel(); - continue; - } - - var url = await BlobUploadService.UploadFileAsync(options.Name, memoryStream, new UploadOptions - { - SetCacheControlHeader = options.CacheMedia, - }); - file.UploadUrl = url; - ToastService.ShowSuccess($"Successfully uploaded {file.Name}"); - } - } - catch (Exception e) - { - ToastService.ShowError($"Error while uploading file: {e.Message}"); - } + s => Task.FromResult(MarkdownConverter.ToMarkupString(s).Value); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + try + { + isMac = await JSRuntime.InvokeAsync("markdownEditor.isMac"); + dotNetHelper = DotNetObjectReference.Create(this); + } + catch (JSException) + { + canUndo = false; + canRedo = false; + return; + } + } + + if (viewMode == ViewMode.Editor && dotNetHelper is not null) + { + try + { + await JSRuntime.InvokeVoidAsync("markdownEditor.setupKeyboardShortcuts", textAreaRef, dotNetHelper); + } + catch (JSException) + { + // Keyboard shortcuts unavailable; toolbar buttons remain functional + } + } + } + + [JSInvokable] + public async Task HandleKeyboardShortcut(string action) + { + switch (action) + { + case "bold": + await InsertBold(); + break; + case "italic": + await InsertItalic(); + break; + case "code": + await InsertCode(); + break; + case "link": + await InsertLink(); + break; + case "quote": + await InsertQuote(); + break; + case "hr": + await InsertHorizontalRule(); + break; + case "preview": + await TogglePreview(); + break; + } + } + + private string GetShortcutText(string action, string key) + { + var modifier = isMac ? "⌘" : "Ctrl"; + return $"{action} ({modifier}+{key})"; + } + + private async Task OnInputAsync() + { + await UpdateUndoRedoState(); + } + + private async Task UpdatePreview() + { + if (viewMode == ViewMode.Preview) + { + previewHtml = await PreviewFunction(Value); + StateHasChanged(); + await InvokeAsync(async () => + { + await JSRuntime.InvokeVoidAsync("markdownEditor.highlightCodeBlocks"); + }); + } + } + + private async Task TogglePreview() + { + viewMode = viewMode == ViewMode.Editor ? ViewMode.Preview : ViewMode.Editor; + if (viewMode == ViewMode.Preview) + { + await UpdatePreview(); + } + } + + private async Task UpdateUndoRedoState() + { + try + { + var state = await JSRuntime.InvokeAsync("markdownEditor.getUndoRedoState", textAreaRef); + canUndo = state.CanUndo; + canRedo = state.CanRedo; + } + catch + { + canUndo = !string.IsNullOrEmpty(Value); + canRedo = false; + } + } + + private async Task Undo() + { + await JSRuntime.InvokeVoidAsync("markdownEditor.undo", textAreaRef); + await UpdateUndoRedoState(); + } + + private async Task Redo() + { + await JSRuntime.InvokeVoidAsync("markdownEditor.redo", textAreaRef); + await UpdateUndoRedoState(); + } + + private Task InsertBold() => InsertMarkdown("**", "**"); + private Task InsertItalic() => InsertMarkdown("*", "*"); + private Task InsertH1() => InsertMarkdown("# ", ""); + private Task InsertH2() => InsertMarkdown("## ", ""); + private Task InsertH3() => InsertMarkdown("### ", ""); + private Task InsertQuote() => InsertLinePrefixMarkdown("> "); + private Task InsertCode() => InsertMarkdown("`", "`"); + private Task InsertCodeBlock() => InsertMarkdown("```\n", "\n```"); + private Task InsertLink() => InsertMarkdown("[", "](url)"); + private Task InsertUnorderedList() => InsertMarkdown("- ", ""); + private Task InsertOrderedList() => InsertMarkdown("1. ", ""); + private Task InsertStrikethrough() => InsertMarkdown("~~", "~~"); + private Task InsertSuperscript() => InsertMarkdown("", ""); + private Task InsertSubscript() => InsertMarkdown("", ""); + private Task InsertTaskList() => InsertMarkdown("- [ ] ", ""); + + private void ToggleTablePicker() => showTablePicker = !showTablePicker; + + private async Task InsertLinePrefixMarkdown(string prefix) + { + try + { + await JSRuntime.InvokeVoidAsync("markdownEditor.insertLinePrefixes", textAreaRef, prefix); + await textAreaRef.FocusAsync(); + } + catch + { + Value += prefix; + } + } + + private async Task InsertHorizontalRule() + { + try + { + await JSRuntime.InvokeVoidAsync("markdownEditor.insertHorizontalRule", textAreaRef); + await textAreaRef.FocusAsync(); + } + catch + { + Value += "\n\n---\n\n"; + } + } + + private async Task InsertTable(int cols, int rows) + { + showTablePicker = false; + var emptyRow = "| " + string.Join(" | ", Enumerable.Repeat("", cols)) + " |"; + var separator = "| " + string.Join(" | ", Enumerable.Repeat("---", cols)) + " |"; + var table = string.Join("\n", new[] { emptyRow, separator }.Concat(Enumerable.Repeat(emptyRow, rows))); + await InsertMarkdown(table, ""); + } + + private async Task InsertMarkdown(string prefix, string suffix) + { + try + { + await JSRuntime.InvokeVoidAsync("markdownEditor.insertText", textAreaRef, prefix, suffix); + await textAreaRef.FocusAsync(); + } + catch + { + Value += prefix + suffix; + } + } + + private async Task ShowImageUpload() + { + await JSRuntime.InvokeVoidAsync("markdownEditor.clickInputFile", inputFileRef.Element); + } + + public async ValueTask DisposeAsync() + { + dotNetHelper?.Dispose(); + } + + private async Task HandleFileSelected(InputFileChangeEventArgs e) + { + foreach (var file in e.GetMultipleFiles()) + { + try + { + if (file.Size > MaxFileSizeBytes) + { + ToastService.ShowError($"File '{file.Name}' exceeds the maximum allowed size of 512 KB."); + continue; + } + + using var memoryStream = new MemoryStream(); + await file.OpenReadStream(maxAllowedSize: MaxFileSizeBytes).CopyToAsync(memoryStream); + memoryStream.Position = 0; + + var options = await UploadDialog.ShowAsync(file.Name); + if (options is null) + { + continue; + } + + var url = await BlobUploadService.UploadFileAsync(options.Name, memoryStream, new UploadOptions + { + SetCacheControlHeader = options.CacheMedia, + }); + + await InsertMarkdown($"![{file.Name}]({url})", ""); + ToastService.ShowSuccess($"Successfully uploaded {file.Name}"); + } + catch (Exception ex) + { + ToastService.ShowError($"Error while uploading file: {ex.Message}"); + } + } } } diff --git a/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj b/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj index abd4b766..0faca88c 100644 --- a/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj +++ b/src/LinkDotNet.Blog.Web/LinkDotNet.Blog.Web.csproj @@ -4,8 +4,6 @@ - - diff --git a/src/LinkDotNet.Blog.Web/Program.cs b/src/LinkDotNet.Blog.Web/Program.cs index 9db10bb3..70459448 100644 --- a/src/LinkDotNet.Blog.Web/Program.cs +++ b/src/LinkDotNet.Blog.Web/Program.cs @@ -46,7 +46,6 @@ private static void RegisterServices(WebApplicationBuilder builder) .AddStorageProvider(builder.Configuration) .AddImageUploadProvider(builder.Configuration) .AddBlazoredToast() - .AddBlazoriseWithBootstrap() .AddResponseCompression() .AddHealthCheckSetup(); diff --git a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs index 3fedb781..493204de 100644 --- a/src/LinkDotNet.Blog.Web/ServiceExtensions.cs +++ b/src/LinkDotNet.Blog.Web/ServiceExtensions.cs @@ -1,7 +1,5 @@ using System; using System.Threading.RateLimiting; -using Blazorise; -using Blazorise.Bootstrap5; using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services; using LinkDotNet.Blog.Web.Features.Admin.Sitemap.Services; using LinkDotNet.Blog.Web.Features.Bookmarks; @@ -53,15 +51,6 @@ public static IServiceCollection AddRateLimiting(this IServiceCollection service return services; } - public static IServiceCollection AddBlazoriseWithBootstrap(this IServiceCollection services) - { - services - .AddBlazorise() - .AddBootstrap5Providers(); - - return services; - } - public static IServiceCollection AddHostingServices(this IServiceCollection services) { services.AddRazorPages(); diff --git a/src/LinkDotNet.Blog.Web/wwwroot/css/markdown-editor.css b/src/LinkDotNet.Blog.Web/wwwroot/css/markdown-editor.css new file mode 100644 index 00000000..3bb34474 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/wwwroot/css/markdown-editor.css @@ -0,0 +1,419 @@ +.markdown-editor-container { + display: flex; + flex-direction: column; + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background-color: var(--bs-body-bg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +[data-bs-theme="dark"] .markdown-editor-container { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + border-color: rgba(255, 255, 255, 0.15); +} + +.markdown-toolbar { + display: flex; + align-items: center; + gap: 0.2rem; + padding: 0.375rem 0.5rem; + background-color: var(--bs-secondary-bg); + border-bottom: 1px solid var(--bs-border-color); + flex-wrap: wrap; + min-height: 2.75rem; +} + +.toolbar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + border-radius: 0.25rem; + background-color: transparent; + color: var(--bs-body-color); + cursor: pointer; + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out, opacity 0.15s ease-in-out; + font-size: 1rem; +} + +.toolbar-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.toolbar-btn:disabled:hover { + background-color: transparent; +} + +.toolbar-btn:hover:not(:disabled) { + background-color: var(--bs-tertiary-bg); + color: var(--bs-emphasis-color); +} + +.toolbar-btn:active:not(:disabled), +.toolbar-btn.active { + background-color: var(--bs-primary); + color: #fff; +} + +.toolbar-btn:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; +} + +.toolbar-divider { + width: 1px; + height: 1.25rem; + background-color: var(--bs-border-color); + margin: 0 0.375rem; +} + +.markdown-preview.preview-only { + width: 100%; + height: 100%; + overflow-y: auto; +} + +.editor-content { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +.markdown-textarea { + width: 100%; + height: 100%; + padding: 1.25rem; + border: none; + outline: none; + resize: none; + background-color: var(--bs-body-bg); + color: var(--bs-body-color); + font-family: 'JetBrains Mono', 'Fira Code', 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 0.9375rem; + line-height: 1.7; + tab-size: 4; + letter-spacing: 0.01em; +} + +[data-bs-theme="dark"] .markdown-textarea { + background-color: rgba(0, 0, 0, 0.2); +} + +.markdown-textarea::placeholder { + color: var(--bs-secondary-color); + opacity: 0.5; + font-style: italic; +} + +.markdown-textarea:focus { + outline: none; + background-color: var(--bs-body-bg); +} + +[data-bs-theme="dark"] .markdown-textarea:focus { + background-color: rgba(0, 0, 0, 0.15); +} + +.editor-split { + display: flex; + width: 100%; + height: 100%; + gap: 1px; + background-color: var(--bs-border-color); +} + +.editor-pane, +.preview-pane { + flex: 1; + overflow: auto; + background-color: var(--bs-body-bg); +} + +.editor-pane { + display: flex; + min-width: 300px; +} + +.preview-pane { + display: flex; + flex-direction: column; + min-width: 300px; + background-color: var(--bs-tertiary-bg); +} + +.markdown-preview { + padding: 1rem; + overflow-y: auto; + height: 100%; + color: var(--bs-body-color); + line-height: 1.6; +} + +.markdown-preview h1, +.markdown-preview h2, +.markdown-preview h3, +.markdown-preview h4, +.markdown-preview h5, +.markdown-preview h6 { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + font-weight: 600; + color: var(--bs-emphasis-color); +} + +.markdown-preview h1 { + font-size: 2rem; + border-bottom: 1px solid var(--bs-border-color); + padding-bottom: 0.5rem; +} + +.markdown-preview h2 { + font-size: 1.5rem; + border-bottom: 1px solid var(--bs-border-color); + padding-bottom: 0.5rem; +} + +.markdown-preview h3 { + font-size: 1.25rem; +} + +.markdown-preview h4 { + font-size: 1.125rem; +} + +.markdown-preview p { + margin-bottom: 1rem; +} + +.markdown-preview code { + padding: 0.2rem 0.4rem; + font-size: 0.875em; + background-color: var(--bs-secondary-bg); + border-radius: 0.25rem; + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; + color: var(--bs-danger); +} + +.markdown-preview pre { + padding: 1rem; + background-color: var(--bs-secondary-bg); + border-radius: 0.375rem; + overflow-x: auto; + margin-bottom: 1rem; + border: 1px solid var(--bs-border-color); +} + +.markdown-preview pre code { + padding: 0; + background-color: transparent; + color: var(--bs-body-color); + font-size: 0.875rem; +} + +.markdown-preview blockquote { + padding: 0.5rem 1rem; + margin: 1rem 0; + border-left: 4px solid var(--bs-primary); + background-color: var(--bs-secondary-bg); + color: var(--bs-body-color); +} + +.markdown-preview blockquote p:last-child { + margin-bottom: 0; +} + +.markdown-preview ul, +.markdown-preview ol { + padding-left: 2rem; + margin-bottom: 1rem; +} + +.markdown-preview li { + margin-bottom: 0.25rem; +} + +.markdown-preview a { + color: var(--bs-link-color); + text-decoration: none; +} + +.markdown-preview a:hover { + color: var(--bs-link-hover-color); + text-decoration: underline; +} + +.markdown-preview img { + max-width: 100%; + height: auto; + border-radius: 0.375rem; + margin: 1rem 0; +} + +.markdown-preview table { + width: 100%; + margin-bottom: 1rem; + border-collapse: collapse; +} + +.markdown-preview table th, +.markdown-preview table td { + padding: 0.5rem; + border: 1px solid var(--bs-border-color); +} + +.markdown-preview table th { + background-color: var(--bs-secondary-bg); + font-weight: 600; + text-align: left; +} + +.markdown-preview hr { + margin: 2rem 0; + border: 0; + border-top: 1px solid var(--bs-border-color); +} + +.markdown-textarea::-webkit-scrollbar, +.preview-pane::-webkit-scrollbar, +.markdown-preview::-webkit-scrollbar { + width: 0.875rem; + height: 0.875rem; +} + +.markdown-textarea::-webkit-scrollbar-track, +.preview-pane::-webkit-scrollbar-track, +.markdown-preview::-webkit-scrollbar-track { + background-color: transparent; +} + +.markdown-textarea::-webkit-scrollbar-thumb, +.preview-pane::-webkit-scrollbar-thumb, +.markdown-preview::-webkit-scrollbar-thumb { + background-color: rgba(128, 128, 128, 0.3); + border-radius: 0.5rem; + border: 2px solid transparent; + background-clip: padding-box; +} + +.markdown-textarea::-webkit-scrollbar-thumb:hover, +.preview-pane::-webkit-scrollbar-thumb:hover, +.markdown-preview::-webkit-scrollbar-thumb:hover { + background-color: rgba(128, 128, 128, 0.5); +} + +[data-bs-theme="dark"] .markdown-textarea::-webkit-scrollbar-thumb, +[data-bs-theme="dark"] .preview-pane::-webkit-scrollbar-thumb, +[data-bs-theme="dark"] .markdown-preview::-webkit-scrollbar-thumb { + background-color: rgba(255, 255, 255, 0.2); +} + +[data-bs-theme="dark"] .markdown-textarea::-webkit-scrollbar-thumb:hover, +[data-bs-theme="dark"] .preview-pane::-webkit-scrollbar-thumb:hover, +[data-bs-theme="dark"] .markdown-preview::-webkit-scrollbar-thumb:hover { + background-color: rgba(255, 255, 255, 0.3); +} + +[data-bs-theme="dark"] .markdown-textarea { + caret-color: #fff; +} + +[data-bs-theme="dark"] .markdown-preview code { + color: #ff6b6b; +} + +@media (max-width: 768px) { + .editor-split { + flex-direction: column; + } + + .editor-pane, + .preview-pane { + min-width: 100%; + min-height: 200px; + } + + .toolbar-btn { + width: 1.75rem; + height: 1.75rem; + font-size: 0.875rem; + } +} + +.markdown-editor-container:focus-within { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; +} + +/* Table popover */ +.table-btn-wrapper { + position: relative; +} + +.table-popover-backdrop { + position: fixed; + inset: 0; + z-index: 1049; + background: transparent; +} + +.table-popover { + position: absolute; + top: calc(100% + 0.375rem); + left: 50%; + transform: translateX(-50%); + z-index: 1050; + min-width: 11rem; + background-color: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +[data-bs-theme="dark"] .table-popover { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); +} + +.table-popover-header { + font-weight: 600; + font-size: 0.8125rem; + color: var(--bs-emphasis-color); + border-bottom: 1px solid var(--bs-border-color); + padding-bottom: 0.4rem; + margin-bottom: 0.1rem; +} + +.table-popover-field { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + font-size: 0.875rem; + color: var(--bs-body-color); + cursor: default; +} + +.table-popover-field input[type="number"] { + width: 4rem; + padding: 0.2rem 0.4rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.25rem; + background-color: var(--bs-body-bg); + color: var(--bs-body-color); + font-size: 0.875rem; + text-align: center; +} + +.table-popover-field input[type="number"]:focus { + outline: 2px solid var(--bs-primary); + outline-offset: 1px; +} diff --git a/src/LinkDotNet.Blog.Web/wwwroot/js/markdown-editor.js b/src/LinkDotNet.Blog.Web/wwwroot/js/markdown-editor.js new file mode 100644 index 00000000..128f38e8 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/wwwroot/js/markdown-editor.js @@ -0,0 +1,188 @@ +window.markdownEditor = { + isMac: function() { + return navigator.platform.toUpperCase().indexOf('MAC') >= 0 || + navigator.userAgent.toUpperCase().indexOf('MAC') >= 0; + }, + + insertLinePrefixes: function(textareaElement, prefix) { + const textarea = textareaElement; + textarea.focus(); + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = textarea.value.substring(start, end); + + if (selectedText) { + const prefixed = selectedText.split('\n').map(line => prefix + line).join('\n'); + document.execCommand('insertText', false, prefixed); + textarea.setSelectionRange(start, start + prefixed.length); + } else { + document.execCommand('insertText', false, prefix); + textarea.setSelectionRange(start + prefix.length, start + prefix.length); + } + + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }, + + insertHorizontalRule: function(textareaElement) { + const textarea = textareaElement; + textarea.focus(); + + const pos = textarea.selectionStart; + const value = textarea.value; + + const needsLeadingNewline = pos > 0 && value[pos - 1] !== '\n'; + const needsTrailingNewline = pos < value.length && value[pos] !== '\n'; + + const rule = (needsLeadingNewline ? '\n' : '') + '---' + (needsTrailingNewline ? '\n' : ''); + document.execCommand('insertText', false, rule); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }, + + insertText: function(textareaElement, prefix, suffix) { + const textarea = textareaElement; + textarea.focus(); + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = textarea.value.substring(start, end); + + if (selectedText) { + document.execCommand('insertText', false, prefix + selectedText + suffix); + const newStart = start + prefix.length; + const newEnd = newStart + selectedText.length; + textarea.setSelectionRange(newEnd, newEnd); + } else { + document.execCommand('insertText', false, prefix + suffix); + const cursorPos = start + prefix.length; + textarea.setSelectionRange(cursorPos, cursorPos); + } + + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }, + + clickInputFile: function(fileInputElement) { + fileInputElement.click(); + }, + + undo: function(textareaElement) { + if (textareaElement && typeof textareaElement.focus === 'function') { + textareaElement.focus(); + document.execCommand('undo'); + } else { + document.execCommand('undo'); + } + }, + + redo: function(textareaElement) { + if (textareaElement && typeof textareaElement.focus === 'function') { + textareaElement.focus(); + document.execCommand('redo'); + } else { + document.execCommand('redo'); + } + }, + + getUndoRedoState: function(textareaElement) { + return { + canUndo: textareaElement.value && textareaElement.value.length > 0, + canRedo: false + }; + }, + + highlightCodeBlocks: function() { + if (typeof hljs !== 'undefined') { + document.querySelectorAll('.markdown-preview pre code').forEach((block) => { + hljs.highlightElement(block); + }); + } + }, + + getFiles: function(fileInput) { + if (!fileInput.files || fileInput.files.length === 0) { + return []; + } + const fileNames = []; + for (let i = 0; i < fileInput.files.length; i++) { + fileNames.push(fileInput.files[i].name); + } + return fileNames; + }, + + readFile: function(fileInput, fileName) { + return new Promise((resolve, reject) => { + const file = Array.from(fileInput.files).find(f => f.name === fileName); + if (!file) { + reject(new Error('File not found')); + return; + } + + const reader = new FileReader(); + reader.onload = function(e) { + const arrayBuffer = e.target.result; + const uint8Array = new Uint8Array(arrayBuffer); + resolve(Array.from(uint8Array)); + }; + reader.onerror = function(e) { + reject(new Error('Failed to read file')); + }; + reader.readAsArrayBuffer(file); + }); + }, + + setupKeyboardShortcuts: function(textareaElement, dotNetHelper) { + if (textareaElement._keyboardListenerAttached) { + return; + } + + const textarea = textareaElement; + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + + textarea.addEventListener('keydown', function(e) { + const cmdOrCtrl = isMac ? e.metaKey : e.ctrlKey; + + if (cmdOrCtrl && !e.shiftKey && !e.altKey) { + switch(e.key.toLowerCase()) { + case 'z': + case 'y': + return; + case 'b': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('HandleKeyboardShortcut', 'bold'); + break; + case 'i': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('HandleKeyboardShortcut', 'italic'); + break; + case 'k': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('HandleKeyboardShortcut', 'link'); + break; + case 'e': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('HandleKeyboardShortcut', 'code'); + break; + } + } else if (cmdOrCtrl && e.shiftKey && !e.altKey) { + switch(e.key.toLowerCase()) { + case 'z': + return; + case 'p': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('HandleKeyboardShortcut', 'preview'); + break; + case '.': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('HandleKeyboardShortcut', 'quote'); + break; + case 'h': + e.preventDefault(); + dotNetHelper.invokeMethodAsync('HandleKeyboardShortcut', 'hr'); + break; + } + } + }); + + textarea._keyboardListenerAttached = true; + } +}; diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/MarkdownTextAreaTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/MarkdownTextAreaTests.cs new file mode 100644 index 00000000..a2cf9190 --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/Components/MarkdownTextAreaTests.cs @@ -0,0 +1,515 @@ +using System.Linq; +using System.Threading.Tasks; +using Blazored.Toast.Services; +using Bunit.TestDoubles; +using LinkDotNet.Blog.Web.Features.Components; +using LinkDotNet.Blog.Web.Features.Services.FileUpload; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; + +namespace LinkDotNet.Blog.UnitTests.Web.Features.Components; + +public class MarkdownTextAreaTests : BunitContext +{ + private void SetupServices() + { + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => Substitute.For()); + JSInterop.Setup("markdownEditor.isMac", _ => true).SetResult(false); + JSInterop.SetupVoid("markdownEditor.setupKeyboardShortcuts", _ => true).SetVoidResult(); + JSInterop.SetupVoid("markdownEditor.setupKeyboardShortcuts", invocation => invocation.Arguments.Count == 2).SetVoidResult(); + JSInterop.SetupVoid("markdownEditor.highlightCodeBlocks"); + } + + [Fact] + public void ShouldRenderTextArea() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "Test content") + .Add(c => c.Rows, 10)); + + var textarea = cut.Find("textarea"); + textarea.ShouldNotBeNull(); + textarea.ClassList.ShouldContain("markdown-textarea"); + } + + [Fact] + public void ShouldRenderToolbar() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var toolbar = cut.Find(".markdown-toolbar"); + toolbar.ShouldNotBeNull(); + + cut.FindAll(".toolbar-btn").Count.ShouldBeGreaterThan(0); + } + + [Fact] + public void ShouldHaveUndoRedoButtons() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var undoButton = cut.Find("button[title*='Undo']"); + var redoButton = cut.Find("button[title*='Redo']"); + + undoButton.ShouldNotBeNull(); + redoButton.ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveBoldItalicButtons() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var boldButton = cut.Find("button[title*='Bold']"); + var italicButton = cut.Find("button[title*='Italic']"); + + boldButton.ShouldNotBeNull(); + italicButton.ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveHeadingButtons() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var h1Button = cut.Find("button[title='Heading 1']"); + var h2Button = cut.Find("button[title='Heading 2']"); + var h3Button = cut.Find("button[title='Heading 3']"); + + h1Button.ShouldNotBeNull(); + h2Button.ShouldNotBeNull(); + h3Button.ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveCodeButtons() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var codeButton = cut.Find("button[title*='Code']"); + var codeBlockButton = cut.Find("button[title='Code Block']"); + + codeButton.ShouldNotBeNull(); + codeBlockButton.ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveLinkAndImageButtons() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var linkButton = cut.Find("button[title*='Link']"); + var imageButton = cut.Find("button[title='Image']"); + + linkButton.ShouldNotBeNull(); + imageButton.ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveListButtons() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var ulButton = cut.Find("button[title='Unordered List']"); + var olButton = cut.Find("button[title='Ordered List']"); + + ulButton.ShouldNotBeNull(); + olButton.ShouldNotBeNull(); + } + + [Fact] + public void ShouldHavePreviewButton() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var previewButton = cut.Find("button[title*='Preview']"); + + previewButton.ShouldNotBeNull(); + previewButton.QuerySelector("i.bi-eye").ShouldNotBeNull(); + } + + [Fact] + public async Task ShouldTogglePreviewMode() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "# Test\n\nSome content") + .Add(c => c.Rows, 10)); + + var previewButton = cut.Find("button[title*='Preview']"); + + await previewButton.ClickAsync(); + + var preview = cut.Find(".markdown-preview"); + preview.ShouldNotBeNull(); + + cut.FindAll("textarea").ShouldBeEmpty(); + } + + [Fact] + public async Task ShouldRenderMarkdownInPreview() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "# Heading\n\nParagraph") + .Add(c => c.Rows, 10)); + + var previewButton = cut.Find("button[title*='Preview']"); + await previewButton.ClickAsync(); + + var preview = cut.Find(".markdown-preview"); + preview.InnerHtml.ShouldContain("(p => p + .Add(c => c.Value, "# Test") + .Add(c => c.Rows, 10)); + + var previewButton = cut.Find("button[title*='Preview']"); + await previewButton.ClickAsync(); + await previewButton.ClickAsync(); + + var textarea = cut.Find("textarea"); + textarea.ShouldNotBeNull(); + + cut.FindAll(".markdown-preview").ShouldBeEmpty(); + } + + [Fact] + public void ShouldSetPlaceholder() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10) + .Add(c => c.Placeholder, "Enter markdown here")); + + var textarea = cut.Find("textarea"); + textarea.GetAttribute("placeholder").ShouldBe("Enter markdown here"); + } + + [Fact] + public void ShouldSetCustomClass() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10) + .Add(c => c.Class, "custom-class")); + + var container = cut.Find(".markdown-editor-container"); + container.ClassList.ShouldContain("custom-class"); + } + + [Fact] + public void ShouldSetCustomId() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10) + .Add(c => c.Id, "custom-id")); + + var container = cut.Find("#custom-id"); + container.ShouldNotBeNull(); + } + + [Fact] + public void ShouldCalculateHeightFromRows() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 20)); + + var editorContent = cut.Find(".editor-content"); + editorContent.ShouldNotBeNull(); + var attribute = editorContent.GetAttribute("style"); + attribute.ShouldNotBeNull(); + attribute.ShouldContain("500px"); + } + + [Fact] + public async Task ShouldInvokeValueChangedCallback() + { + SetupServices(); + + var valueChanged = false; + var newValue = string.Empty; + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10) + .Add(c => c.ValueChanged, v => { valueChanged = true; newValue = v; })); + + var textarea = cut.Find("textarea"); + await textarea.InputAsync("New content"); + + valueChanged.ShouldBeTrue(); + newValue.ShouldBe("New content"); + } + + [Fact] + public void ShouldHaveInputFileComponent() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var inputFile = cut.Find("input[type='file']"); + inputFile.ShouldNotBeNull(); + inputFile.GetAttribute("accept").ShouldBe("image/*"); + var attribute = inputFile.GetAttribute("style"); + attribute.ShouldNotBeNull(); + attribute.ShouldContain("display: none"); + } + + [Fact] + public async Task ShouldCallSetupKeyboardShortcutsOnFirstRender() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + await cut.InvokeAsync(() => Task.CompletedTask); + + JSInterop.Invocations.ShouldContain(i => i.Identifier == "markdownEditor.setupKeyboardShortcuts"); + } + + [Fact] + public void ShouldHaveStrikethroughButton() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var button = cut.Find("button[title='Strikethrough']"); + button.ShouldNotBeNull(); + button.QuerySelector("i.bi-type-strikethrough").ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveSuperscriptAndSubscriptButtons() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + cut.Find("button[title='Superscript']").ShouldNotBeNull(); + cut.Find("button[title='Subscript']").ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveTableButton() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var tableButton = cut.Find("button[title='Table']"); + tableButton.ShouldNotBeNull(); + tableButton.QuerySelector("i.bi-table").ShouldNotBeNull(); + } + + [Fact] + public async Task ShouldShowTablePickerWhenTableButtonClicked() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + cut.FindAll(".table-popover").ShouldBeEmpty(); + + await cut.Find("button[title='Table']").ClickAsync(); + + cut.Find(".table-popover").ShouldNotBeNull(); + } + + [Fact] + public async Task ShouldHideTablePickerWhenTableButtonClickedAgain() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var tableButton = cut.Find("button[title='Table']"); + await tableButton.ClickAsync(); + await cut.Find("button[title='Table']").ClickAsync(); + + cut.FindAll(".table-popover").ShouldBeEmpty(); + } + + [Fact] + public async Task ShouldInsertCorrectTableMarkdown() + { + SetupServices(); + JSInterop.Setup("markdownEditor.insertText", _ => true).SetException(new JSException("no JS")); + + var capturedValue = string.Empty; + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10) + .Add(c => c.ValueChanged, v => capturedValue = v)); + + await cut.Find("button[title='Table']").ClickAsync(); + + var insertButton = cut.Find(".table-popover .btn-primary"); + await insertButton.ClickAsync(); + + capturedValue.ShouldContain("| | |"); + capturedValue.ShouldContain("| --- | --- |"); + cut.FindAll(".table-popover").ShouldBeEmpty(); + } + + [Fact] + public void ShouldHaveTaskListButton() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var button = cut.Find("button[title='Task List']"); + button.ShouldNotBeNull(); + button.QuerySelector("i.bi-list-check").ShouldNotBeNull(); + } + + [Fact] + public void ShouldHaveHorizontalRuleButton() + { + SetupServices(); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var button = cut.Find("button[title*='Horizontal Rule']"); + button.ShouldNotBeNull(); + button.QuerySelector("i.bi-hr").ShouldNotBeNull(); + } + + [Fact] + public async Task ShouldCallInsertLinePrefixesForBlockQuote() + { + SetupServices(); + JSInterop.SetupVoid("markdownEditor.insertLinePrefixes", _ => true); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + await cut.Find("button[title*='Quote']").ClickAsync(); + + var invocation = JSInterop.Invocations + .FirstOrDefault(i => i.Identifier == "markdownEditor.insertLinePrefixes"); + invocation.Identifier.ShouldBe("markdownEditor.insertLinePrefixes"); + invocation.Arguments[1].ShouldBe("> "); + } + + [Fact] + public async Task ShouldInsertHorizontalRuleViaFallback() + { + SetupServices(); + JSInterop.Setup("markdownEditor.insertHorizontalRule", _ => true) + .SetException(new JSException("no JS")); + + var capturedValue = string.Empty; + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10) + .Add(c => c.ValueChanged, v => capturedValue = v)); + + await cut.Find("button[title*='Horizontal Rule']").ClickAsync(); + + capturedValue.ShouldContain("---"); + } + + [Fact] + public async Task ShouldShowErrorWhenFileExceedsMaxSize() + { + var toastService = Substitute.For(); + Services.AddScoped(_ => Substitute.For()); + Services.AddScoped(_ => toastService); + JSInterop.Setup("markdownEditor.isMac", _ => true).SetResult(false); + JSInterop.SetupVoid("markdownEditor.setupKeyboardShortcuts", _ => true).SetVoidResult(); + JSInterop.SetupVoid("markdownEditor.highlightCodeBlocks"); + + var cut = Render(p => p + .Add(c => c.Value, "") + .Add(c => c.Rows, 10)); + + var mockFile = Substitute.For(); + mockFile.Size.Returns(600 * 1024L); // 600 KB > 512 KB + mockFile.Name.Returns("large-image.jpg"); + + var inputFile = cut.FindComponent(); + await cut.InvokeAsync(() => inputFile.Instance.OnChange.InvokeAsync( + new InputFileChangeEventArgs(new[] { mockFile }))); + + toastService.Received(1).ShowError( + Arg.Is(msg => msg.Contains("large-image.jpg") && msg.Contains("512 KB"))); + } +}