feat(markdown): add MarkdownEditView with lazy BlockNote editor + dark theme styles
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
// Shared style constants + BlockNote dark-theme CSS string for injection
|
||||||
|
|
||||||
|
export const MD_BLOCKNOTE_DARK_CSS = `
|
||||||
|
.bn-container[data-theme="dark"] {
|
||||||
|
--bn-colors-editor-background: #232336;
|
||||||
|
--bn-colors-editor-text: #e0e0e0;
|
||||||
|
--bn-colors-menu-background: #2a2a42;
|
||||||
|
--bn-colors-menu-text: #e0e0e0;
|
||||||
|
--bn-colors-tooltip-background: #333;
|
||||||
|
--bn-colors-tooltip-text: #ccc;
|
||||||
|
--bn-colors-hovered-background: rgba(121,80,242,0.15);
|
||||||
|
--bn-colors-selected-background: rgba(121,80,242,0.25);
|
||||||
|
--bn-colors-side-menu: #666;
|
||||||
|
--bn-font-family: Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* MarkdownEditView — BlockNote editor wrapper for markdown card edit mode.
|
||||||
|
* Lazy-loaded on first double-click via dynamic import().
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useCreateBlockNote } from '@blocknote/react';
|
||||||
|
import { BlockNoteViewRaw } from '@blocknote/react';
|
||||||
|
import '@blocknote/react/style.css';
|
||||||
|
|
||||||
|
interface MarkdownEditViewProps {
|
||||||
|
initialContent: string;
|
||||||
|
accentColor: string;
|
||||||
|
onSave: (newContent: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown conversion is built into the BlockNote editor instance:
|
||||||
|
// - editor.tryParseMarkdownToBlocks(md) — markdown → blocks
|
||||||
|
// - editor.blocksToMarkdownLossy(blocks) — blocks → markdown
|
||||||
|
// No separate @blocknote/xl-markdown package needed.
|
||||||
|
|
||||||
|
export default function MarkdownEditView(props: MarkdownEditViewProps) {
|
||||||
|
const { initialContent, accentColor, onSave, onCancel } = props;
|
||||||
|
const savedRef = useRef(false);
|
||||||
|
|
||||||
|
const editor = useCreateBlockNote({
|
||||||
|
domAttributes: {
|
||||||
|
editor: {
|
||||||
|
'data-theme': 'dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize editor content from markdown
|
||||||
|
useEffect(() => {
|
||||||
|
const blocks = editor.tryParseMarkdownToBlocks(initialContent);
|
||||||
|
editor.replaceBlocks(editor.document, blocks);
|
||||||
|
}, []); // Only on mount
|
||||||
|
|
||||||
|
const doSave = useCallback(() => {
|
||||||
|
if (savedRef.current) return;
|
||||||
|
savedRef.current = true;
|
||||||
|
const md = editor.blocksToMarkdownLossy(editor.document);
|
||||||
|
onSave(md);
|
||||||
|
}, [editor, onSave]);
|
||||||
|
|
||||||
|
const doCancel = useCallback(() => {
|
||||||
|
if (savedRef.current) return;
|
||||||
|
savedRef.current = true;
|
||||||
|
onCancel();
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
// Ctrl+Enter to save, Escape to cancel
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
doSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
doCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown, true); // capture phase
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown, true);
|
||||||
|
}, [doSave, doCancel]);
|
||||||
|
|
||||||
|
// Click outside to save
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest('[data-markdown-edit]')) {
|
||||||
|
doSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.addEventListener('pointerdown', onClick);
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('pointerdown', onClick);
|
||||||
|
};
|
||||||
|
}, [doSave]);
|
||||||
|
|
||||||
|
// Header with Done/Cancel controls
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
padding: '7px 14px',
|
||||||
|
background: '#2a2a42',
|
||||||
|
borderBottom: '1px solid #333',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: '11px',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-markdown-edit
|
||||||
|
style={{
|
||||||
|
border: `1.5px solid ${accentColor}`,
|
||||||
|
borderRadius: '10px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: `0 4px 24px ${accentColor}33`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={headerStyle}>
|
||||||
|
<span style={{ color: '#ccc' }}>Editing</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span style={{ color: '#666', fontSize: '10px' }}>Esc cancel</span>
|
||||||
|
<button
|
||||||
|
onClick={doSave}
|
||||||
|
style={{
|
||||||
|
background: accentColor,
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '3px 10px',
|
||||||
|
fontSize: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ minHeight: '100px' }}>
|
||||||
|
<BlockNoteViewRaw editor={editor} theme="dark" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user