fix(markdown): polish editor UX — side panel, overlay sync, paste segregation
- Move markdown editor from canvas overlay to side panel (70% width) for better performance and editing experience - Add @blocknote/mantine for full BlockNoteView with default UI components - Fix socket reconnect loop caused by unstable pasteOpts object reference (memoize with useMemo) - Add ResizeObserver to markdown overlay cards for automatic height sync - Call mdOverlay.refreshAll() on every canvas change so overlays track pack/grid/arrange/save operations - Paste goes to BlockNote editor when contentEditable is focused - Simplify toolbar: single color picker (accent + auto-derived bg), title/name field, width presets S/M/L - Guard normalize/flip operations to skip markdown and sticky items - Fix title not updating on card preview (pass name prop, bump revision) - Fix preview not refreshing after editor save (revision counter + overlay refresh)
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* MarkdownEditView — BlockNote editor wrapper for markdown card edit mode.
|
||||
* MarkdownEditView — BlockNote editor in a side panel.
|
||||
* 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';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import '@blocknote/mantine/style.css';
|
||||
import { MD_BLOCKNOTE_DARK_CSS } from '../canvas/markdownStyles';
|
||||
|
||||
interface MarkdownEditViewProps {
|
||||
@@ -16,11 +16,6 @@ interface MarkdownEditViewProps {
|
||||
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);
|
||||
@@ -74,70 +69,67 @@ export default function MarkdownEditView(props: MarkdownEditViewProps) {
|
||||
doCancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown, true); // capture phase
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
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`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={headerStyle}>
|
||||
<span style={{ color: '#ccc' }}>Editing</span>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
background: '#1a1a2e',
|
||||
borderBottom: `2px solid ${accentColor}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: '#ccc', fontSize: '12px', fontWeight: 500 }}>Markdown Editor</span>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{ color: '#666', fontSize: '10px' }}>Esc cancel</span>
|
||||
<span style={{ color: '#666', fontSize: '10px' }}>Esc cancel · Ctrl+Enter save</span>
|
||||
<button
|
||||
onClick={doCancel}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: '#aaa',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={doSave}
|
||||
style={{
|
||||
background: accentColor,
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '3px 10px',
|
||||
fontSize: '10px',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minHeight: '100px' }}>
|
||||
<BlockNoteViewRaw editor={editor} theme="dark" />
|
||||
|
||||
{/* Editor */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<BlockNoteView editor={editor} theme="dark" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* MarkdownFormatToolbar — contextual toolbar for selected markdown cards.
|
||||
* Shows background color picker, width presets, and accent color picker.
|
||||
* Shows name, color picker (sets accent + auto-derives bg), and width presets.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
@@ -8,22 +8,37 @@ import React, { useState, useEffect } from 'react';
|
||||
interface MarkdownFormatToolbarProps {
|
||||
x: number;
|
||||
y: number;
|
||||
bgColor: string;
|
||||
accentColor: string;
|
||||
width: number;
|
||||
onBgColorChange: (color: string) => void;
|
||||
onAccentColorChange: (color: string) => void;
|
||||
name: string;
|
||||
onColorChange: (accent: string, bg: string) => void;
|
||||
onWidthChange: (width: number) => void;
|
||||
onNameChange: (name: string) => void;
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#232336', '#1e1e2e', '#2a2a3a', '#1a2332', '#2a1a2a',
|
||||
'#1a2a1a', '#2a2a1a', '#333333', '#1a1a1a', '#3a2a42',
|
||||
];
|
||||
/** Derive a dark card background from an accent color. */
|
||||
function deriveBg(accent: string): string {
|
||||
// Parse hex to RGB, then darken heavily
|
||||
const hex = accent.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16) || 0;
|
||||
const g = parseInt(hex.substring(2, 4), 16) || 0;
|
||||
const b = parseInt(hex.substring(4, 6), 16) || 0;
|
||||
// Mix ~15% accent into a dark base
|
||||
const mix = (c: number) => Math.round(28 + c * 0.12);
|
||||
return `#${mix(r).toString(16).padStart(2, '0')}${mix(g).toString(16).padStart(2, '0')}${mix(b).toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const ACCENT_COLORS = [
|
||||
'#7950f2', '#4dabf7', '#69db7c', '#ffd43b', '#ff6b6b',
|
||||
'#e64980', '#ffa94d', '#868e96', '#ffffff', '#f783ac',
|
||||
const CARD_COLORS = [
|
||||
{ accent: '#7950f2', label: 'Purple' },
|
||||
{ accent: '#4dabf7', label: 'Blue' },
|
||||
{ accent: '#69db7c', label: 'Green' },
|
||||
{ accent: '#ffd43b', label: 'Yellow' },
|
||||
{ accent: '#ff6b6b', label: 'Red' },
|
||||
{ accent: '#e64980', label: 'Pink' },
|
||||
{ accent: '#ffa94d', label: 'Orange' },
|
||||
{ accent: '#868e96', label: 'Gray' },
|
||||
{ accent: '#f783ac', label: 'Rose' },
|
||||
{ accent: '#20c997', label: 'Teal' },
|
||||
];
|
||||
|
||||
const WIDTH_PRESETS = [
|
||||
@@ -33,12 +48,11 @@ const WIDTH_PRESETS = [
|
||||
];
|
||||
|
||||
export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps) {
|
||||
const { x, y, bgColor, accentColor, width, onBgColorChange, onAccentColorChange, onWidthChange } = props;
|
||||
const [showBgPicker, setShowBgPicker] = useState(false);
|
||||
const [showAccentPicker, setShowAccentPicker] = useState(false);
|
||||
const { x, y, accentColor, width, name, onColorChange, onWidthChange, onNameChange } = props;
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = () => { setShowBgPicker(false); setShowAccentPicker(false); };
|
||||
const onDown = () => setShowPicker(false);
|
||||
window.addEventListener('pointerdown', onDown);
|
||||
return () => window.removeEventListener('pointerdown', onDown);
|
||||
}, []);
|
||||
@@ -62,19 +76,40 @@ export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps)
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Background color */}
|
||||
{/* Title / name */}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
placeholder="Untitled card"
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid transparent',
|
||||
borderRadius: '5px', color: '#ccc', fontSize: '11px',
|
||||
padding: '2px 8px', width: '120px', outline: 'none',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
}}
|
||||
onFocus={(e) => { (e.target as HTMLInputElement).style.borderColor = '#555'; }}
|
||||
onBlur={(e) => { (e.target as HTMLInputElement).style.borderColor = 'transparent'; }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
/>
|
||||
|
||||
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||
|
||||
{/* Card color */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
||||
onClick={(e) => { e.stopPropagation(); setShowBgPicker((v) => !v); setShowAccentPicker(false); }}
|
||||
title="Card background"
|
||||
style={{ ...btnStyle, gap: '4px', padding: '0 8px' }}
|
||||
onClick={(e) => { e.stopPropagation(); setShowPicker((v) => !v); }}
|
||||
title="Card color"
|
||||
>
|
||||
<div style={{ width: '14px', height: '14px', borderRadius: '3px', background: bgColor, border: '1px solid #555' }} />
|
||||
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: accentColor, border: '1px solid #555' }} />
|
||||
<span style={{ fontSize: '10px', color: '#888' }}>Color</span>
|
||||
</button>
|
||||
{showBgPicker && (
|
||||
{showPicker && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
||||
position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||
marginTop: '4px',
|
||||
background: 'rgba(22, 22, 22, 0.98)', border: '1px solid #333',
|
||||
borderRadius: '8px', padding: '8px', zIndex: 200,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
@@ -82,13 +117,14 @@ export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps)
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px' }}>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
{CARD_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { onBgColorChange(c); setShowBgPicker(false); }}
|
||||
key={c.accent}
|
||||
onClick={() => { onColorChange(c.accent, deriveBg(c.accent)); setShowPicker(false); }}
|
||||
title={c.label}
|
||||
style={{
|
||||
width: '22px', height: '22px', borderRadius: '4px', background: c,
|
||||
border: c === bgColor ? '2px solid #4a90d9' : '1px solid #444',
|
||||
width: '22px', height: '22px', borderRadius: '4px', background: c.accent,
|
||||
border: c.accent === accentColor ? '2px solid #fff' : '1px solid #444',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
/>
|
||||
@@ -117,44 +153,6 @@ export default function MarkdownFormatToolbar(props: MarkdownFormatToolbarProps)
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ width: '1px', height: '18px', background: '#333', margin: '0 2px', flexShrink: 0 }} />
|
||||
|
||||
{/* Accent color */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
style={{ ...btnStyle, width: '26px', padding: 0 }}
|
||||
onClick={(e) => { e.stopPropagation(); setShowAccentPicker((v) => !v); setShowBgPicker(false); }}
|
||||
title="Accent color"
|
||||
>
|
||||
<div style={{ width: '14px', height: '14px', borderRadius: '6px', background: accentColor, border: '1px solid #555' }} />
|
||||
</button>
|
||||
{showAccentPicker && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: '4px',
|
||||
background: 'rgba(22, 22, 22, 0.98)', border: '1px solid #333',
|
||||
borderRadius: '8px', padding: '8px', zIndex: 200,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '4px' }}>
|
||||
{ACCENT_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => { onAccentColorChange(c); setShowAccentPicker(false); }}
|
||||
style={{
|
||||
width: '22px', height: '22px', borderRadius: '4px', background: c,
|
||||
border: c === accentColor ? '2px solid #4a90d9' : '1px solid #444',
|
||||
cursor: 'pointer', padding: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface MarkdownReadViewProps {
|
||||
accentColor: string;
|
||||
bgColor: string;
|
||||
padding: number;
|
||||
name?: string;
|
||||
onCheckboxToggle?: (newContent: string) => void;
|
||||
}
|
||||
|
||||
@@ -70,13 +71,13 @@ function toggleCheckbox(content: string, index: number): string {
|
||||
}
|
||||
|
||||
export default function MarkdownReadView(props: MarkdownReadViewProps) {
|
||||
const { content, textColor, accentColor, bgColor: _bgColor, padding, onCheckboxToggle } = props;
|
||||
const { content, textColor, accentColor, bgColor: _bgColor, padding, name, onCheckboxToggle } = props;
|
||||
|
||||
const displayContent = content.length > MD_MAX_CONTENT_LENGTH
|
||||
? content.slice(0, MD_MAX_CONTENT_LENGTH) + '\n\n---\n*Content truncated*'
|
||||
: content;
|
||||
|
||||
const title = extractTitle(content);
|
||||
const title = name || extractTitle(content);
|
||||
|
||||
const checkboxIndexRef = React.useRef(0);
|
||||
checkboxIndexRef.current = 0;
|
||||
|
||||
Reference in New Issue
Block a user