From 79fc43d592671c52417658ea013620746704cb95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 8 Jun 2026 13:47:06 +0200 Subject: [PATCH] feat: implement stage 1 of Milkdown WYSIWYG editor integration --- .../Components/MarkdownEditor.razor | 97 +++++++++++++++++++ .../Components/MarkdownEditor.razor.css | 81 ++++++++++++++++ .../Pages/CreatorTest.razor | 72 ++++++++++++++ .../Pages/CreatorTest.razor.css | 75 ++++++++++++++ .../wwwroot/js/milkdownWrapper.js | 86 ++++++++++++++++ 5 files changed, 411 insertions(+) create mode 100644 src/NexusReader.UI.Shared/Components/MarkdownEditor.razor create mode 100644 src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css create mode 100644 src/NexusReader.UI.Shared/Pages/CreatorTest.razor create mode 100644 src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css create mode 100644 src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor new file mode 100644 index 0000000..0adbe19 --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor @@ -0,0 +1,97 @@ +@using Microsoft.JSInterop +@implements IAsyncDisposable +@inject IJSRuntime JS + +
+
+
+ +
+
+ +@code { + private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}"; + private IJSObjectReference? _module; + private DotNetObjectReference? _dotNetHelper; + + [Parameter] + public string InitialMarkdown { get; set; } = string.Empty; + + [Parameter] + public EventCallback OnSave { get; set; } + + [Parameter] + public string Height { get; set; } = "500px"; + + [Parameter] + public string Width { get; set; } = "100%"; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetHelper = DotNetObjectReference.Create(this); + try + { + // Import the isolated JavaScript module + _module = await JS.InvokeAsync( + "import", + "./_content/NexusReader.UI.Shared/js/milkdownWrapper.js" + ); + + // Call the initialization function in the wrapper + await _module.InvokeVoidAsync("initEditor", EditorId, _dotNetHelper, InitialMarkdown); + } + catch (Exception ex) + { + // Log the exception gracefully and do not crash the component + Console.WriteLine($"[MarkdownEditor] Error initializing Milkdown editor: {ex.Message}"); + } + } + } + + private async Task FetchContentAsync() + { + if (_module is not null) + { + try + { + // Retrieve the updated markdown from JS + var markdown = await _module.InvokeAsync("getMarkdownContent", EditorId); + + if (OnSave.HasDelegate) + { + await OnSave.InvokeAsync(markdown); + } + } + catch (Exception ex) + { + Console.WriteLine($"[MarkdownEditor] Error fetching markdown content: {ex.Message}"); + } + } + } + + public async ValueTask DisposeAsync() + { + try + { + if (_module is not null) + { + // Clean up the JS editor instance to prevent memory leaks + await _module.InvokeVoidAsync("destroyEditor", EditorId); + await _module.DisposeAsync(); + } + } + catch (Exception ex) + { + // Fail silently during page navigation/webview closures to avoid noisy logs + Console.WriteLine($"[MarkdownEditor] Error during JS cleanup: {ex.Message}"); + } + finally + { + _dotNetHelper?.Dispose(); + } + } +} diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css new file mode 100644 index 0000000..2b1841a --- /dev/null +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor.css @@ -0,0 +1,81 @@ +.markdown-editor-container { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.milkdown-editor-wrapper { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-surface); + overflow: auto; + padding: 1.5rem; + position: relative; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.milkdown-editor-wrapper:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-glow); +} + +.editor-actions { + display: flex; + justify-content: flex-end; +} + +/* 3. Bypassing Blazor CSS Isolation for Dynamic JS DOMs using ::deep */ +::deep .milkdown-editor-wrapper .crepe { + max-width: 100% !important; +} + +::deep .milkdown-editor-wrapper .milkdown { + background-color: var(--bg-surface) !important; + color: var(--text-main) !important; + font-family: var(--nexus-font-sans) !important; + border: none !important; + box-shadow: none !important; + + /* Map Crepe's internal variables to our design tokens */ + --crepe-color-background: var(--bg-surface); + --crepe-color-on-background: var(--text-main); + --crepe-color-surface: rgba(255, 255, 255, 0.03); + --crepe-color-surface-low: rgba(255, 255, 255, 0.01); + --crepe-color-primary: var(--accent); + --crepe-color-outline: var(--border); +} + +::deep .milkdown-editor-wrapper .milkdown .editor { + color: var(--text-main) !important; + background: transparent !important; + outline: none !important; + padding: 0.5rem 0 !important; + min-height: 200px; +} + +/* Style the buttons using variables from app.css */ +.nexus-btn { + font-family: var(--nexus-font-sans); + font-weight: 600; + border-radius: var(--radius-md); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border: none; + text-decoration: none; + background: var(--nexus-neon); + color: #000000; + padding: 8px 16px; + font-size: 0.9rem; + min-height: 36px; +} + +.nexus-btn:hover { + transform: translateY(-2px); + filter: brightness(1.1); + box-shadow: 0 4px 15px var(--nexus-primary-glow); +} diff --git a/src/NexusReader.UI.Shared/Pages/CreatorTest.razor b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor new file mode 100644 index 0000000..8d40fa7 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor @@ -0,0 +1,72 @@ +@page "/dev/creator-test" +@using Microsoft.AspNetCore.Authorization +@attribute [AllowAnonymous] + +Markdown Creator Test + +
+
+

Milkdown WYSIWYG Integration (Stage 1)

+

Verifying bi-directional Markdown flow and GFM rendering.

+
+ +
+ +
+ +
+

Retrieved Markdown Content

+

This block shows the content received from the editor when you click "Fetch Markdown Content".

+
+ @if (string.IsNullOrEmpty(_savedMarkdown)) + { + No content fetched yet. Click "Fetch Markdown Content" above to retrieve data. + } + else + { +
@_savedMarkdown
+ } +
+
+
+ +@code { + private readonly string _initialMarkdown = @"# Milkdown WYSIWYG Test Page + +This is a demonstration of the **Milkdown** editor embedded inside a Blazor WASM component. + +## GFM Features Support + +The editor supports Github Flavored Markdown out-of-the-box: + +1. **Task Lists** + - [x] Create reusable Blazor component + - [x] Configure ESM dynamic wrapper + - [ ] Implement stage 2 features + +2. **Tables** + +| Feature | Stage 1 Status | Stage 2 Plan | +| :--- | :---: | :---: | +| WYSIWYG Mode | Active | Polish UI | +| C# Interop | Done | Auto-Sync | +| GFM Support | Verified | Custom Nodes | + +3. **Code Formatting** +```csharp +public class MarkdownEditor : ComponentBase +{ + // C# interop logic +} +``` + +Feel free to edit this text and click **Fetch Markdown Content** below!"; + + private string _savedMarkdown = string.Empty; + + private void HandleSave(string markdown) + { + _savedMarkdown = markdown; + StateHasChanged(); + } +} diff --git a/src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css new file mode 100644 index 0000000..4e4fa67 --- /dev/null +++ b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css @@ -0,0 +1,75 @@ +.creator-test-container { + max-width: 1000px; + margin: 2rem auto; + padding: 2rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.test-header h1 { + font-size: 1.75rem; + color: var(--text-main); + margin: 0 0 0.5rem 0; +} + +.test-header .subtitle { + font-size: 0.95rem; + color: var(--text-muted); + margin: 0; +} + +.editor-section { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + overflow: hidden; +} + +.result-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; +} + +.result-section h3 { + margin: 0; + font-size: 1.2rem; + color: var(--text-main); +} + +.result-section .description { + font-size: 0.85rem; + color: var(--text-muted); + margin: 0 0 0.5rem 0; +} + +.pre-wrapper { + background: #09090b; + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 1.2rem; + max-height: 400px; + overflow-y: auto; +} + +.pre-wrapper pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + font-family: monospace; + font-size: 0.9rem; + color: #e4e4e7; + line-height: 1.5; +} + +.placeholder { + color: var(--text-muted); + font-size: 0.9rem; + font-style: italic; +} diff --git a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js new file mode 100644 index 0000000..5d4649e --- /dev/null +++ b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js @@ -0,0 +1,86 @@ +// Map to keep track of active Crepe editor instances by elementId (container ID) +const editors = new Map(); + +/** + * Asynchronously injects a stylesheet link tag into the document head + * and returns a Promise that resolves when the stylesheet is fully loaded. + */ +async function injectStylesheet(url) { + if (document.querySelector(`link[href="${url}"]`)) { + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.onload = () => resolve(); + link.onerror = (err) => reject(new Error(`Failed to load stylesheet: ${url}. ${err}`)); + document.head.appendChild(link); + }); +} + +/** + * Initializes a Milkdown Crepe editor on the specified element. + */ +export async function initEditor(elementId, dotNetHelper, initialMarkdown) { + const container = document.getElementById(elementId); + if (!container) { + console.error(`[Milkdown] Container with ID "${elementId}" not found.`); + return; + } + + try { + // Condition 2: Prevent FOUC by loading stylesheets before instantiating the editor + await Promise.all([ + injectStylesheet('https://esm.sh/@milkdown/crepe/theme/common/style.css'), + injectStylesheet('https://esm.sh/@milkdown/crepe/theme/frame.css') + ]); + + // Dynamically import the Crepe ESM module + const { Crepe } = await import('https://esm.sh/@milkdown/crepe'); + + // Initialize the Crepe editor instance + const crepe = new Crepe({ + root: container, + defaultValue: initialMarkdown || "", + }); + + // Store the editor instance in the map + editors.set(elementId, crepe); + + // Create the editor view asynchronously + await crepe.create(); + + console.log(`[Milkdown] Editor successfully initialized on element: ${elementId}`); + } catch (error) { + console.error(`[Milkdown] Failed to initialize editor on "${elementId}":`, error); + } +} + +/** + * Retrieves the current Markdown content from a specific editor instance. + */ +export function getMarkdownContent(elementId) { + const crepe = editors.get(elementId); + if (!crepe) { + console.warn(`[Milkdown] No editor instance found for element: ${elementId}`); + return ""; + } + return crepe.getMarkdown(); +} + +/** + * Safely disposes of the editor instance to prevent memory leaks in WASM. + */ +export async function destroyEditor(elementId) { + const crepe = editors.get(elementId); + if (crepe) { + try { + await crepe.destroy(); + console.log(`[Milkdown] Editor instance successfully destroyed: ${elementId}`); + } catch (error) { + console.error(`[Milkdown] Error destroying editor for element "${elementId}":`, error); + } + editors.delete(elementId); + } +}