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, ); })}