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
+
+
+
+
+
+
+
+
+
+
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);
+ }
+}