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

## Summary of Changes
This pull request aligns all major interactive editor control elements in the Milkdown Crepe editor with the premium `SelectionAiPanel` / `IntelligenceToolbar` glassmorphism design.

### Changes:
1. **Selection Bubble Menu Unification:** Relocated the selection menu overrides from `Creator.razor.css` to `app.css` to resolve scoping bugs. Themed to match the Reader's selection popup 1:1.
2. **Editor Controls Theming:** Themed table cell drag handles, table actions popups, line insertion handles & add buttons, Notion-style paragraph drag handles, and slash commands menus with glassmorphic backgrounds, perimeter borders, hover transitions, and active accent states.
3. **Visibility Lifecycle Fixes:** Excluded `.cell-handle` and `.milkdown-block-handle` from explicit `display: none !important` rules when hidden, preserving their dimensions for correct JS positioning calculations and preventing handles from jumping/sliding.
4. **Table Margin Clipping Fix:** Set `overflow: visible !important` on `.tableWrapper` to allow table controls to draw cleanly into the editor canvas's padding zone without boundary clipping.

Resolves #82.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #81
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #81.
This commit is contained in:
2026-06-11 18:07:51 +00:00
committed by Marek Jaisński
parent 9fddafa423
commit ec3fc52a73
24 changed files with 2851 additions and 5 deletions
@@ -0,0 +1,104 @@
// Map to keep track of active Crepe editor instances by elementId (container ID)
const editorCache = 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 ensureStylesheet(href) {
if (document.querySelector(`link[href="${href}"]`)) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.onload = () => resolve();
link.onerror = (err) => reject(new Error(`Failed to load stylesheet: ${href}. ${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 ensureStylesheet('/_content/NexusReader.UI.Shared/css/vendor/milkdown-crepe.css');
// Dynamically import the local JS bundle
await import('/_content/NexusReader.UI.Shared/js/vendor/milkdown-crepe.js');
// Get Crepe constructor from the global window.milkdownCrepe namespace
const Crepe = window.milkdownCrepe?.Crepe;
if (!Crepe) {
throw new Error("Crepe constructor not found on window.milkdownCrepe");
}
// Initialize the Crepe editor instance with custom ImageBlock upload handler
const crepe = new Crepe({
root: container,
defaultValue: initialMarkdown || "",
featureConfigs: {
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
try {
const arrayBuffer = await file.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, uint8Array);
return url;
} catch (err) {
console.error("[Milkdown] Failed to upload image from JS:", err);
throw err;
}
}
}
}
});
// Store the editor instance in the map
editorCache.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 = editorCache.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 = editorCache.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);
}
editorCache.delete(elementId);
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long