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:
Hiren Kangad
2026-03-13 11:21:28 +05:30
parent a7579a9ff5
commit accbe2e0f3
9 changed files with 531 additions and 142 deletions
@@ -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>
);
}