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
+30 -3
View File
@@ -21,6 +21,8 @@ interface CardEntry {
contentMount: HTMLDivElement;
/** Current item ID. */
id: string;
/** ResizeObserver for auto-height updates. */
resizeObserver: ResizeObserver;
}
export class MarkdownOverlay {
@@ -33,8 +35,8 @@ export class MarkdownOverlay {
/** Callback when a card's height changes (from DOM measurement). */
onHeightChange: ((id: string, newHeight: number) => void) | null = null;
/** Callback to enter edit mode for a card (wired by Editor.tsx). */
onRequestEdit: ((id: string) => void) | null = null;
/** Callback to enter/exit edit mode for a card (wired by Editor.tsx). */
onRequestEdit: ((id: string | null) => void) | null = null;
/** Callback when checkbox is toggled in read mode. */
onCheckboxToggle: ((id: string, newContent: string) => void) | null = null;
@@ -64,6 +66,8 @@ export class MarkdownOverlay {
for (const [cardId, entry] of this._cards) {
entry.el.style.pointerEvents = cardId === id ? 'auto' : 'none';
}
// Notify React to open/close the side panel editor
this.onRequestEdit?.(id);
}
get editingId(): string | null {
@@ -171,8 +175,14 @@ export class MarkdownOverlay {
const contentMount = document.createElement('div');
el.appendChild(contentMount);
// Auto-measure height whenever DOM content changes size
const resizeObserver = new ResizeObserver(() => {
this._syncHeight(item.id);
});
resizeObserver.observe(el);
this._container.appendChild(el);
this._cards.set(item.id, { el, contentMount, id: item.id });
this._cards.set(item.id, { el, contentMount, id: item.id, resizeObserver });
this.onChange?.();
}
@@ -191,9 +201,26 @@ export class MarkdownOverlay {
entry.el.style.transform = `scale(${zoom})`;
}
/** Sync data.h from DOM measurement — called by ResizeObserver. */
private _syncHeight(id: string): void {
const entry = this._cards.get(id);
const item = this._scene.getById(id);
if (!entry || !item || item.type !== 'markdown') return;
const h = entry.el.offsetHeight;
if (h > 0 && Math.abs(h - item.data.h) > 1) {
item.data.h = h;
if (item.displayObject instanceof MarkdownSprite) {
item.displayObject.updateFromData(item.data as MarkdownObject);
}
this.onHeightChange?.(id, h);
}
}
private _removeCard(id: string): void {
const entry = this._cards.get(id);
if (!entry) return;
entry.resizeObserver.disconnect();
entry.el.remove();
this._cards.delete(id);
this.onChange?.();
+4
View File
@@ -330,6 +330,10 @@ export function setupPaste(
},
): () => void {
async function onPaste(e: ClipboardEvent) {
// If focus is inside a contentEditable (e.g. BlockNote editor), let native paste through
const active = document.activeElement;
if (active instanceof HTMLElement && (active.isContentEditable || active.closest('[contenteditable]'))) return;
const items = e.clipboardData?.items;
if (!items) return;
+24 -9
View File
@@ -11,6 +11,11 @@ import { applyImageDisplayTransform, getImageDisplayTransform } from './imageTra
// ─── Helpers ───
/** Items with fixed dimensions — scale must stay at 1. */
function isFixedSize(item: SceneItem): boolean {
return item.type === 'markdown' || item.type === 'sticky';
}
function scaledW(item: SceneItem): number {
return item.data.w * item.data.sx;
}
@@ -182,9 +187,11 @@ export function distributeVertical(objects: SceneItem[]) {
export function normalizeSize(objects: SceneItem[]) {
if (objects.length < 2) return;
const areas = objects.map((item) => scaledW(item) * scaledH(item));
const scalable = objects.filter(i => !isFixedSize(i));
if (scalable.length < 2) return;
const areas = scalable.map((item) => scaledW(item) * scaledH(item));
const avgArea = areas.reduce((a, b) => a + b, 0) / areas.length;
objects.forEach((item) => {
scalable.forEach((item) => {
const currentArea = scaledW(item) * scaledH(item);
if (currentArea <= 0) return;
const ratio = Math.sqrt(avgArea / currentArea);
@@ -196,9 +203,11 @@ export function normalizeSize(objects: SceneItem[]) {
export function normalizeScale(objects: SceneItem[]) {
if (objects.length < 2) return;
const avgSX = objects.reduce((s, item) => s + item.data.sx, 0) / objects.length;
const avgSY = objects.reduce((s, item) => s + item.data.sy, 0) / objects.length;
objects.forEach((item) => {
const scalable = objects.filter(i => !isFixedSize(i));
if (scalable.length < 2) return;
const avgSX = scalable.reduce((s, item) => s + item.data.sx, 0) / scalable.length;
const avgSY = scalable.reduce((s, item) => s + item.data.sy, 0) / scalable.length;
scalable.forEach((item) => {
item.data.sx = avgSX;
item.data.sy = avgSY;
syncScale(item);
@@ -207,8 +216,10 @@ export function normalizeScale(objects: SceneItem[]) {
export function normalizeHeight(objects: SceneItem[]) {
if (objects.length < 2) return;
const avgH = objects.reduce((s, item) => s + scaledH(item), 0) / objects.length;
objects.forEach((item) => {
const scalable = objects.filter(i => !isFixedSize(i));
if (scalable.length < 2) return;
const avgH = scalable.reduce((s, item) => s + scaledH(item), 0) / scalable.length;
scalable.forEach((item) => {
const h = scaledH(item);
if (h <= 0) return;
const ratio = avgH / h;
@@ -220,8 +231,10 @@ export function normalizeHeight(objects: SceneItem[]) {
export function normalizeWidth(objects: SceneItem[]) {
if (objects.length < 2) return;
const avgW = objects.reduce((s, item) => s + scaledW(item), 0) / objects.length;
objects.forEach((item) => {
const scalable = objects.filter(i => !isFixedSize(i));
if (scalable.length < 2) return;
const avgW = scalable.reduce((s, item) => s + scaledW(item), 0) / scalable.length;
scalable.forEach((item) => {
const w = scaledW(item);
if (w <= 0) return;
const ratio = avgW / w;
@@ -367,6 +380,7 @@ function layoutAsGrid(sorted: SceneItem[], anchor: { x: number; y: number }) {
export function flipHorizontal(objects: SceneItem[]) {
objects.forEach((item) => {
if (isFixedSize(item)) return;
item.data.flipX = !item.data.flipX;
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
@@ -378,6 +392,7 @@ export function flipHorizontal(objects: SceneItem[]) {
export function flipVertical(objects: SceneItem[]) {
objects.forEach((item) => {
if (isFixedSize(item)) return;
item.data.flipY = !item.data.flipY;
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
+41 -49
View File
@@ -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>
);
}
+3 -2
View File
@@ -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;
+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}