feat(editor): align selection popup and all editor control elements styling with Reader #81
@@ -0,0 +1,97 @@
|
|||||||
|
@using Microsoft.JSInterop
|
||||||
|
|
|||||||
|
@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 {
|
||||||
|
Antigravity
commented
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user
Component implements IAsyncDisposable correctly; consider adding cancellation token to async calls.