feat(editor): align selection popup and all editor control elements styling with Reader #81

Merged
mjasin merged 14 commits from feature/milkdown-integration into develop 2026-06-11 18:07:53 +00:00
5 changed files with 411 additions and 0 deletions
Showing only changes of commit 79fc43d592 - Show all commits
@@ -0,0 +1,97 @@
@using Microsoft.JSInterop
Review

Component implements IAsyncDisposable correctly; consider adding cancellation token to async calls.

Component implements IAsyncDisposable correctly; consider adding cancellation token to async calls.
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
<div id="@EditorId" class="milkdown-editor-wrapper"></div>
<div class="editor-actions">
<button type="button" @onclick="FetchContentAsync" class="nexus-btn">
Fetch Markdown Content
</button>
</div>
</div>
@code {
private readonly string EditorId = $"milkdown-editor-{Guid.NewGuid():N}";
private IJSObjectReference? _module;
private DotNetObjectReference<MarkdownEditor>? _dotNetHelper;
[Parameter]
public string InitialMarkdown { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> 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<IJSObjectReference>(
"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<string>("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();
}
}
}
@@ -0,0 +1,81 @@
.markdown-editor-container {
Review

Add :focus-visible outline for .nexus-btn to improve keyboard accessibility.

Add :focus-visible outline for .nexus-btn to improve keyboard accessibility.
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);
}
@@ -0,0 +1,72 @@
@page "/dev/creator-test"
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
<PageTitle>Markdown Creator Test</PageTitle>
<div class="creator-test-container glass-panel">
<div class="test-header">
<h1>Milkdown WYSIWYG Integration (Stage 1)</h1>
<p class="subtitle">Verifying bi-directional Markdown flow and GFM rendering.</p>
</div>
<div class="editor-section">
<MarkdownEditor InitialMarkdown="@_initialMarkdown" OnSave="HandleSave" Height="400px" />
</div>
<div class="result-section">
<h3>Retrieved Markdown Content</h3>
<p class="description">This block shows the content received from the editor when you click "Fetch Markdown Content".</p>
<div class="pre-wrapper">
@if (string.IsNullOrEmpty(_savedMarkdown))
{
<span class="placeholder">No content fetched yet. Click "Fetch Markdown Content" above to retrieve data.</span>
}
else
{
<pre><code>@_savedMarkdown</code></pre>
}
</div>
</div>
</div>
@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();
}
}
@@ -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;
}
@@ -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);
}
}