diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx
index fe24d2e..647596c 100644
--- a/frontend/src/components/Toolbar.tsx
+++ b/frontend/src/components/Toolbar.tsx
@@ -32,6 +32,8 @@ interface ToolbarProps {
onToggleHelp?: () => void;
onMmImport?: () => void;
onExport?: () => void;
+ onRefreshPreview?: () => void;
+ previewRefreshing?: boolean;
onToggleReview?: () => void;
reviewMode?: boolean;
boardName?: string;
@@ -165,6 +167,8 @@ export default function Toolbar({
onToggleHelp,
onMmImport,
onExport,
+ onRefreshPreview,
+ previewRefreshing,
onToggleReview,
reviewMode,
}: ToolbarProps) {
@@ -346,6 +350,20 @@ export default function Toolbar({
)}
+ {/* Refresh board preview */}
+ {onRefreshPreview && (
+
+
+
+ )}
+
{/* Spacer */}
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx
index ee2365e..1cc3de5 100644
--- a/frontend/src/pages/Editor.tsx
+++ b/frontend/src/pages/Editor.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useState, useCallback } from 'react';
+import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import PixiCanvas, { PixiCanvasHandle } from '../canvas/PixiCanvas';
import { SelectionManager } from '../canvas/SelectionManager';
@@ -45,6 +45,7 @@ import ReactDOM from 'react-dom';
import MarkdownReadView from '../components/MarkdownReadView';
import PasteChoicePopup from '../components/PasteChoicePopup';
import MarkdownFormatToolbar from '../components/MarkdownFormatToolbar';
+import { saveCanvas } from '../api';
const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView'));
// Hooks
@@ -112,6 +113,7 @@ export default function Editor({ isPublicView }: EditorProps) {
const [showHelp, setShowHelp] = useState(false);
const [showMmImport, setShowMmImport] = useState(false);
const [showExport, setShowExport] = useState(false);
+ const [refreshingPreview, setRefreshingPreview] = useState(false);
const [reviewMode, setReviewMode] = useState(false);
const reviewModeRef = useRef(false);
const [focusedThreadId, setFocusedThreadId] = useState(null);
@@ -236,15 +238,18 @@ export default function Editor({ isPublicView }: EditorProps) {
onCanvasChange([textData.id]);
}, [canvasRef, onCanvasChange]);
+ // Memoize pasteOpts so useCanvasSetup's effect doesn't re-run every render
+ const pasteOpts = useMemo(() => ({
+ onTextPaste: handleTextPaste,
+ onShortTextPaste: handleShortTextPaste,
+ }), [handleTextPaste, handleShortTextPaste]);
+
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
const { annotationStore, pinOverlay, textEditor, cropOverlayRef, mdOverlay } = useCanvasSetup({
boardData, resolvedBoardId, user, isPublicView,
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
- pasteOpts: {
- onTextPaste: handleTextPaste,
- onShortTextPaste: handleShortTextPaste,
- },
+ pasteOpts,
onCropModeChange: setCropModeActive,
});
@@ -574,6 +579,38 @@ export default function Editor({ isPublicView }: EditorProps) {
}
}, [showToast]);
+ const handleRefreshPreview = useCallback(async () => {
+ if (!resolvedBoardId || isPublicView || readOnly || refreshingPreview) return;
+ const scene = canvasRef.current?.getScene();
+ const viewport = canvasRef.current?.getViewport();
+ const app = canvasRef.current?.getApp();
+ if (!scene || !viewport || !app?.renderer?.extract) {
+ showToast('Preview snapshot is not available');
+ return;
+ }
+
+ setRefreshingPreview(true);
+ try {
+ const extractedCanvas = app.renderer.extract.canvas(viewport) as HTMLCanvasElement;
+ const maxWidth = 900;
+ const scale = extractedCanvas.width > maxWidth ? maxWidth / extractedCanvas.width : 1;
+ const out = document.createElement('canvas');
+ out.width = Math.max(1, Math.round(extractedCanvas.width * scale));
+ out.height = Math.max(1, Math.round(extractedCanvas.height * scale));
+ const ctx = out.getContext('2d');
+ if (!ctx) throw new Error('2D context unavailable');
+ ctx.drawImage(extractedCanvas, 0, 0, out.width, out.height);
+ const thumbnail = out.toDataURL('image/jpeg', 0.82);
+ await saveCanvas(resolvedBoardId, JSON.stringify(scene.serialize()), thumbnail);
+ setSaveStatus('saved');
+ showToast('Board preview updated');
+ } catch (err: any) {
+ showToast('Preview update failed: ' + (err.message || 'unknown error'));
+ } finally {
+ setRefreshingPreview(false);
+ }
+ }, [resolvedBoardId, isPublicView, readOnly, refreshingPreview, showToast]);
+
const startCropForSelection = useCallback((showInvalidToast = false) => {
const selection = selectionRef.current;
if (!selection || !cropOverlayRef.current) return;
@@ -926,12 +963,15 @@ export default function Editor({ isPublicView }: EditorProps) {
onToggleHelp={() => setShowHelp((v) => !v)}
onMmImport={() => setShowMmImport(true)}
onExport={() => setShowExport(true)}
+ onRefreshPreview={readOnly || isPublicView ? undefined : handleRefreshPreview}
+ previewRefreshing={refreshingPreview}
onToggleReview={() => setReviewMode((v) => !v)}
reviewMode={reviewMode}
boardName={board?.name}
/>}
- {/* Canvas */}
+ {/* Canvas + Markdown edit panel row */}
+
e.preventDefault()} onDrop={(e) => e.preventDefault()}>
{/* Dot grid — behind transparent canvas */}
{showGrid && (() => {
@@ -1431,6 +1471,46 @@ export default function Editor({ isPublicView }: EditorProps) {
+ {/* Markdown editor side panel */}
+ {editingMdId && (() => {
+ const item = canvasRef.current?.getScene()?.getById(editingMdId);
+ if (!item || item.type !== 'markdown') return null;
+ const data = item.data as MarkdownObject;
+ return (
+
+ Loading editor...
+ }>
+ {
+ data.content = newContent;
+ setEditingMdId(null);
+ mdOverlay?.setEditing(null);
+ selectionRef.current?.setEnabled(true);
+ onCanvasChange([editingMdId]);
+ mdOverlay?.measureHeight(editingMdId);
+ }}
+ onCancel={() => {
+ setEditingMdId(null);
+ mdOverlay?.setEditing(null);
+ selectionRef.current?.setEnabled(true);
+ }}
+ />
+
+
+ );
+ })()}
+ {/* end flex row */}
+
{/* Status bar */}
{!focusMode &&
)}
- {/* Markdown card portals */}
+ {/* Markdown card portals (read-only previews on canvas) */}
{mdCardIds.map(id => {
const mountPoint = mdOverlay?.getMountPoint(id);
const item = canvasRef.current?.getScene()?.getById(id);
if (!mountPoint || !item || item.type !== 'markdown') return null;
const data = item.data as MarkdownObject;
- const isEditing = editingMdId === id;
return ReactDOM.createPortal(
- isEditing ? (
- Loading editor...}>
- {
- data.content = newContent;
- setEditingMdId(null);
- mdOverlay?.setEditing(null);
- selectionRef.current?.setEnabled(true);
- onCanvasChange([id]);
- mdOverlay?.measureHeight(id);
- }}
- onCancel={() => {
- setEditingMdId(null);
- mdOverlay?.setEditing(null);
- selectionRef.current?.setEnabled(true);
- }}
- />
-
- ) : (
- {
- data.content = newContent;
- onCanvasChange([id]);
- mdOverlay?.measureHeight(id);
- }}
- />
- ),
+ {
+ data.content = newContent;
+ onCanvasChange([id]);
+ mdOverlay?.measureHeight(id);
+ }}
+ />,
mountPoint,
);
})}