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
+55 -14
View File
@@ -40,6 +40,7 @@ import type { TextObject, StickyObject, MarkdownObject } from '../canvas/scene-f
import { VideoSprite } from '../canvas/sprites/VideoSprite';
import { TextSprite } from '../canvas/sprites/TextSprite';
import { StickySprite } from '../canvas/sprites/StickySprite';
import { MarkdownSprite } from '../canvas/sprites/MarkdownSprite';
import * as ops from '../canvas/operations';
import ReactDOM from 'react-dom';
import MarkdownReadView from '../components/MarkdownReadView';
@@ -88,6 +89,7 @@ export default function Editor({ isPublicView }: EditorProps) {
const inboxZoneRef = useRef<InboxZone | null>(null);
const clipboardRef = useRef<SceneItem[]>([]);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const mdOverlayRef = useRef<any>(null);
const [uploadManager] = useState(() => new UploadManager());
// Reset upload manager when switching boards
@@ -173,6 +175,8 @@ export default function Editor({ isPublicView }: EditorProps) {
const onCanvasChange = useCallback((changedIds?: string[]) => {
scheduleSave();
setSceneVersion(v => v + 1);
// Reposition all markdown overlay divs to match canvas items
mdOverlayRef.current?.refreshAll();
if (changedIds && changedIds.length > 0) {
// Incremental: broadcast only changed elements
syncRef.current?.broadcastElements(changedIds);
@@ -256,9 +260,11 @@ export default function Editor({ isPublicView }: EditorProps) {
// ── Markdown overlay — sync visible card IDs for React portal rendering ──
const [mdCardIds, setMdCardIds] = useState<string[]>([]);
const [editingMdId, setEditingMdId] = useState<string | null>(null);
const [mdRevision, setMdRevision] = useState(0);
useEffect(() => {
if (!mdOverlay) return;
mdOverlayRef.current = mdOverlay;
const syncIds = () => {
const scene = canvasRef.current?.getScene();
@@ -276,7 +282,14 @@ export default function Editor({ isPublicView }: EditorProps) {
};
mdOverlay.onChange = syncIds;
mdOverlay.onRequestEdit = (id) => setEditingMdId(id);
mdOverlay.onRequestEdit = (id) => {
setEditingMdId(id);
if (id) {
selectionRef.current?.setEnabled(false);
} else {
selectionRef.current?.setEnabled(true);
}
};
syncIds();
return () => {
mdOverlay.onChange = null;
@@ -1144,22 +1157,37 @@ export default function Editor({ isPublicView }: EditorProps) {
<MarkdownFormatToolbar
x={mdToolbar.x}
y={mdToolbar.y}
bgColor={(mdToolbar.item.data as MarkdownObject).bgColor}
accentColor={(mdToolbar.item.data as MarkdownObject).accentColor}
width={mdToolbar.item.data.w}
onBgColorChange={(color) => {
(mdToolbar.item.data as MarkdownObject).bgColor = color;
onCanvasChange([mdToolbar.item.id]);
}}
onAccentColorChange={(color) => {
(mdToolbar.item.data as MarkdownObject).accentColor = color;
name={mdToolbar.item.data.name || ''}
onColorChange={(accent, bg) => {
const data = mdToolbar.item.data as MarkdownObject;
data.accentColor = accent;
data.bgColor = bg;
if (mdToolbar.item.displayObject instanceof MarkdownSprite) {
mdToolbar.item.displayObject.updateFromData(data);
}
mdOverlay?.updateItem(mdToolbar.item.id);
onCanvasChange([mdToolbar.item.id]);
updateOverlays();
}}
onWidthChange={(w) => {
mdToolbar.item.data.w = w;
onCanvasChange([mdToolbar.item.id]);
const data = mdToolbar.item.data as MarkdownObject;
data.w = w;
if (mdToolbar.item.displayObject instanceof MarkdownSprite) {
mdToolbar.item.displayObject.updateFromData(data);
}
mdOverlay?.updateItem(mdToolbar.item.id);
mdOverlay?.measureHeight(mdToolbar.item.id);
onCanvasChange([mdToolbar.item.id]);
selectionRef.current?.transformBox.update([mdToolbar.item]);
updateOverlays();
}}
onNameChange={(n) => {
mdToolbar.item.data.name = n;
setMdToolbar({ ...mdToolbar });
setMdRevision(r => r + 1);
onCanvasChange([mdToolbar.item.id]);
}}
/>
)}
@@ -1478,7 +1506,7 @@ export default function Editor({ isPublicView }: EditorProps) {
const data = item.data as MarkdownObject;
return (
<div style={{
width: '420px', minWidth: '420px',
width: '70%', minWidth: '400px', maxWidth: '900px',
background: '#1a1a2e',
borderLeft: '1px solid #333',
display: 'flex', flexDirection: 'column',
@@ -1492,12 +1520,24 @@ export default function Editor({ isPublicView }: EditorProps) {
initialContent={data.content}
accentColor={data.accentColor}
onSave={(newContent) => {
const savedId = editingMdId!;
data.content = newContent;
const itemRef = canvasRef.current?.getScene()?.getById(savedId);
if (itemRef?.displayObject instanceof MarkdownSprite) {
itemRef.displayObject.updateFromData(data);
}
setEditingMdId(null);
mdOverlay?.setEditing(null);
selectionRef.current?.setEnabled(true);
onCanvasChange([editingMdId]);
mdOverlay?.measureHeight(editingMdId);
setMdRevision(r => r + 1);
onCanvasChange([savedId]);
mdOverlay?.updateItem(savedId);
// Measure height after React re-renders the portal with new content.
// Multiple passes: react-markdown renders async, DOM needs layout time.
const measure = () => mdOverlay?.measureHeight(savedId);
setTimeout(measure, 50);
setTimeout(measure, 200);
setTimeout(measure, 500);
}}
onCancel={() => {
setEditingMdId(null);
@@ -1544,8 +1584,9 @@ export default function Editor({ isPublicView }: EditorProps) {
const data = item.data as MarkdownObject;
return ReactDOM.createPortal(
<MarkdownReadView
key={id}
key={`${id}-${mdRevision}`}
content={data.content}
name={data.name}
textColor={data.textColor}
accentColor={data.accentColor}
bgColor={data.bgColor}