refboard: add manual board preview refresh button
This commit is contained in:
@@ -32,6 +32,8 @@ interface ToolbarProps {
|
|||||||
onToggleHelp?: () => void;
|
onToggleHelp?: () => void;
|
||||||
onMmImport?: () => void;
|
onMmImport?: () => void;
|
||||||
onExport?: () => void;
|
onExport?: () => void;
|
||||||
|
onRefreshPreview?: () => void;
|
||||||
|
previewRefreshing?: boolean;
|
||||||
onToggleReview?: () => void;
|
onToggleReview?: () => void;
|
||||||
reviewMode?: boolean;
|
reviewMode?: boolean;
|
||||||
boardName?: string;
|
boardName?: string;
|
||||||
@@ -165,6 +167,8 @@ export default function Toolbar({
|
|||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
onMmImport,
|
onMmImport,
|
||||||
onExport,
|
onExport,
|
||||||
|
onRefreshPreview,
|
||||||
|
previewRefreshing,
|
||||||
onToggleReview,
|
onToggleReview,
|
||||||
reviewMode,
|
reviewMode,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
@@ -346,6 +350,20 @@ export default function Toolbar({
|
|||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Refresh board preview */}
|
||||||
|
{onRefreshPreview && (
|
||||||
|
<ActionBtn
|
||||||
|
onClick={onRefreshPreview}
|
||||||
|
title={previewRefreshing ? 'Refreshing board preview...' : 'Update board preview from current view'}
|
||||||
|
disabled={previewRefreshing}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||||
|
<path d="M11.5 4.5A4.5 4.5 0 104 11" strokeLinecap="round" />
|
||||||
|
<path d="M11.5 1.8v2.7H8.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
|||||||
+100
-44
@@ -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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import PixiCanvas, { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
import PixiCanvas, { PixiCanvasHandle } from '../canvas/PixiCanvas';
|
||||||
import { SelectionManager } from '../canvas/SelectionManager';
|
import { SelectionManager } from '../canvas/SelectionManager';
|
||||||
@@ -45,6 +45,7 @@ import ReactDOM from 'react-dom';
|
|||||||
import MarkdownReadView from '../components/MarkdownReadView';
|
import MarkdownReadView from '../components/MarkdownReadView';
|
||||||
import PasteChoicePopup from '../components/PasteChoicePopup';
|
import PasteChoicePopup from '../components/PasteChoicePopup';
|
||||||
import MarkdownFormatToolbar from '../components/MarkdownFormatToolbar';
|
import MarkdownFormatToolbar from '../components/MarkdownFormatToolbar';
|
||||||
|
import { saveCanvas } from '../api';
|
||||||
const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView'));
|
const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView'));
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
@@ -112,6 +113,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [showMmImport, setShowMmImport] = useState(false);
|
const [showMmImport, setShowMmImport] = useState(false);
|
||||||
const [showExport, setShowExport] = useState(false);
|
const [showExport, setShowExport] = useState(false);
|
||||||
|
const [refreshingPreview, setRefreshingPreview] = useState(false);
|
||||||
const [reviewMode, setReviewMode] = useState(false);
|
const [reviewMode, setReviewMode] = useState(false);
|
||||||
const reviewModeRef = useRef(false);
|
const reviewModeRef = useRef(false);
|
||||||
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(null);
|
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(null);
|
||||||
@@ -236,15 +238,18 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onCanvasChange([textData.id]);
|
onCanvasChange([textData.id]);
|
||||||
}, [canvasRef, onCanvasChange]);
|
}, [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)
|
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
|
||||||
const { annotationStore, pinOverlay, textEditor, cropOverlayRef, mdOverlay } = useCanvasSetup({
|
const { annotationStore, pinOverlay, textEditor, cropOverlayRef, mdOverlay } = useCanvasSetup({
|
||||||
boardData, resolvedBoardId, user, isPublicView,
|
boardData, resolvedBoardId, user, isPublicView,
|
||||||
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
|
||||||
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
|
||||||
pasteOpts: {
|
pasteOpts,
|
||||||
onTextPaste: handleTextPaste,
|
|
||||||
onShortTextPaste: handleShortTextPaste,
|
|
||||||
},
|
|
||||||
onCropModeChange: setCropModeActive,
|
onCropModeChange: setCropModeActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -574,6 +579,38 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
}
|
}
|
||||||
}, [showToast]);
|
}, [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 startCropForSelection = useCallback((showInvalidToast = false) => {
|
||||||
const selection = selectionRef.current;
|
const selection = selectionRef.current;
|
||||||
if (!selection || !cropOverlayRef.current) return;
|
if (!selection || !cropOverlayRef.current) return;
|
||||||
@@ -926,12 +963,15 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onToggleHelp={() => setShowHelp((v) => !v)}
|
onToggleHelp={() => setShowHelp((v) => !v)}
|
||||||
onMmImport={() => setShowMmImport(true)}
|
onMmImport={() => setShowMmImport(true)}
|
||||||
onExport={() => setShowExport(true)}
|
onExport={() => setShowExport(true)}
|
||||||
|
onRefreshPreview={readOnly || isPublicView ? undefined : handleRefreshPreview}
|
||||||
|
previewRefreshing={refreshingPreview}
|
||||||
onToggleReview={() => setReviewMode((v) => !v)}
|
onToggleReview={() => setReviewMode((v) => !v)}
|
||||||
reviewMode={reviewMode}
|
reviewMode={reviewMode}
|
||||||
boardName={board?.name}
|
boardName={board?.name}
|
||||||
/>}
|
/>}
|
||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas + Markdown edit panel row */}
|
||||||
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
<div ref={canvasContainerRef} style={{ flex: 1, position: 'relative', overflow: 'hidden', background: '#1e1e1e' }} onContextMenu={handleContextMenu} onDragOver={(e) => e.preventDefault()} onDrop={(e) => e.preventDefault()}>
|
<div ref={canvasContainerRef} style={{ flex: 1, position: 'relative', overflow: 'hidden', background: '#1e1e1e' }} onContextMenu={handleContextMenu} onDragOver={(e) => e.preventDefault()} onDrop={(e) => e.preventDefault()}>
|
||||||
{/* Dot grid — behind transparent canvas */}
|
{/* Dot grid — behind transparent canvas */}
|
||||||
{showGrid && (() => {
|
{showGrid && (() => {
|
||||||
@@ -1431,6 +1471,46 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div style={{
|
||||||
|
width: '420px', minWidth: '420px',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
borderLeft: '1px solid #333',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<React.Suspense fallback={
|
||||||
|
<div style={{ padding: 24, color: '#888', fontSize: 13 }}>Loading editor...</div>
|
||||||
|
}>
|
||||||
|
<LazyMarkdownEditView
|
||||||
|
key={`${editingMdId}-edit`}
|
||||||
|
initialContent={data.content}
|
||||||
|
accentColor={data.accentColor}
|
||||||
|
onSave={(newContent) => {
|
||||||
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</React.Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>{/* end flex row */}
|
||||||
|
|
||||||
{/* Status bar */}
|
{/* Status bar */}
|
||||||
{!focusMode && <StatusBar
|
{!focusMode && <StatusBar
|
||||||
boardName={board?.name || 'Untitled'}
|
boardName={board?.name || 'Untitled'}
|
||||||
@@ -1456,50 +1536,26 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Markdown card portals */}
|
{/* Markdown card portals (read-only previews on canvas) */}
|
||||||
{mdCardIds.map(id => {
|
{mdCardIds.map(id => {
|
||||||
const mountPoint = mdOverlay?.getMountPoint(id);
|
const mountPoint = mdOverlay?.getMountPoint(id);
|
||||||
const item = canvasRef.current?.getScene()?.getById(id);
|
const item = canvasRef.current?.getScene()?.getById(id);
|
||||||
if (!mountPoint || !item || item.type !== 'markdown') return null;
|
if (!mountPoint || !item || item.type !== 'markdown') return null;
|
||||||
const data = item.data as MarkdownObject;
|
const data = item.data as MarkdownObject;
|
||||||
const isEditing = editingMdId === id;
|
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
isEditing ? (
|
<MarkdownReadView
|
||||||
<React.Suspense fallback={<div style={{ padding: 16, color: '#999' }}>Loading editor...</div>}>
|
key={id}
|
||||||
<LazyMarkdownEditView
|
content={data.content}
|
||||||
key={`${id}-edit`}
|
textColor={data.textColor}
|
||||||
initialContent={data.content}
|
accentColor={data.accentColor}
|
||||||
accentColor={data.accentColor}
|
bgColor={data.bgColor}
|
||||||
onSave={(newContent) => {
|
padding={data.padding}
|
||||||
data.content = newContent;
|
onCheckboxToggle={(newContent) => {
|
||||||
setEditingMdId(null);
|
data.content = newContent;
|
||||||
mdOverlay?.setEditing(null);
|
onCanvasChange([id]);
|
||||||
selectionRef.current?.setEnabled(true);
|
mdOverlay?.measureHeight(id);
|
||||||
onCanvasChange([id]);
|
}}
|
||||||
mdOverlay?.measureHeight(id);
|
/>,
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
setEditingMdId(null);
|
|
||||||
mdOverlay?.setEditing(null);
|
|
||||||
selectionRef.current?.setEnabled(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</React.Suspense>
|
|
||||||
) : (
|
|
||||||
<MarkdownReadView
|
|
||||||
key={id}
|
|
||||||
content={data.content}
|
|
||||||
textColor={data.textColor}
|
|
||||||
accentColor={data.accentColor}
|
|
||||||
bgColor={data.bgColor}
|
|
||||||
padding={data.padding}
|
|
||||||
onCheckboxToggle={(newContent) => {
|
|
||||||
data.content = newContent;
|
|
||||||
onCanvasChange([id]);
|
|
||||||
mdOverlay?.measureHeight(id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
mountPoint,
|
mountPoint,
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user