feat: remember zoom and pan position per board across sessions

Viewport transform (zoom level + pan offset) is saved to localStorage
keyed by board ID. Restored automatically when reopening the same board.
Debounced 300ms save on zoom/pan to avoid excessive writes.
This commit is contained in:
Hiren
2026-03-09 18:31:55 +05:30
parent cdeafce7ad
commit 00c3370634
2 changed files with 50 additions and 5 deletions
+49 -5
View File
@@ -12,14 +12,31 @@ export interface FabricCanvasHandle {
interface FabricCanvasProps { interface FabricCanvasProps {
canvasState?: string | null; canvasState?: string | null;
currentTool: string; currentTool: string;
boardId?: string;
onChange?: () => void; onChange?: () => void;
} }
const MIN_ZOOM = 0.1; const MIN_ZOOM = 0.1;
const MAX_ZOOM = 5.0; const MAX_ZOOM = 5.0;
function viewportKey(id: string) { return `refboard:viewport:${id}`; }
function saveViewport(id: string, vpt: number[]) {
try { localStorage.setItem(viewportKey(id), JSON.stringify(vpt)); } catch {}
}
function loadViewport(id: string): number[] | null {
try {
const raw = localStorage.getItem(viewportKey(id));
if (!raw) return null;
const arr = JSON.parse(raw);
if (Array.isArray(arr) && arr.length === 6 && arr.every((v: any) => typeof v === 'number' && isFinite(v))) return arr;
} catch {}
return null;
}
const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>( const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
({ canvasState, currentTool, onChange }, ref) => { ({ canvasState, currentTool, boardId, onChange }, ref) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasElRef = useRef<HTMLCanvasElement>(null); const canvasElRef = useRef<HTMLCanvasElement>(null);
const fabricRef = useRef<Canvas | null>(null); const fabricRef = useRef<Canvas | null>(null);
@@ -49,6 +66,17 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
fabricRef.current = canvas; fabricRef.current = canvas;
// Debounced viewport persistence
let vpTimer: ReturnType<typeof setTimeout> | null = null;
const persistViewport = () => {
if (!boardId) return;
if (vpTimer) clearTimeout(vpTimer);
vpTimer = setTimeout(() => {
const vpt = canvas.viewportTransform;
if (vpt) saveViewport(boardId, [...vpt]);
}, 300);
};
// Handle resize // Handle resize
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
const w = container.clientWidth; const w = container.clientWidth;
@@ -76,6 +104,7 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
vpt[5] += e.e.clientY - lastPanPoint.current.y; vpt[5] += e.e.clientY - lastPanPoint.current.y;
lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY }; lastPanPoint.current = { x: e.e.clientX, y: e.e.clientY };
canvas.requestRenderAll(); canvas.requestRenderAll();
persistViewport();
}); });
canvas.on('mouse:up', () => { canvas.on('mouse:up', () => {
@@ -113,6 +142,7 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
const point = canvas.getScenePoint(e); const point = canvas.getScenePoint(e);
canvas.zoomToPoint(point, zoom); canvas.zoomToPoint(point, zoom);
canvas.requestRenderAll(); canvas.requestRenderAll();
persistViewport();
}); });
// Space key for pan // Space key for pan
@@ -146,6 +176,7 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
changeEvents.forEach((evt) => canvas.on(evt as any, changeHandler)); changeEvents.forEach((evt) => canvas.on(evt as any, changeHandler));
return () => { return () => {
if (vpTimer) clearTimeout(vpTimer);
observer.disconnect(); observer.disconnect();
window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp); window.removeEventListener('keyup', onKeyUp);
@@ -196,6 +227,13 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
// other clients with object:add events for objects they already have // other clients with object:add events for objects they already have
suppressBroadcasts(); suppressBroadcasts();
canvas.loadFromJSON(parsed).then(() => { canvas.loadFromJSON(parsed).then(() => {
// Restore saved viewport (zoom + pan) if available
if (boardId) {
const savedVpt = loadViewport(boardId);
if (savedVpt) {
canvas.setViewportTransform(savedVpt as any);
}
}
canvas.requestRenderAll(); canvas.requestRenderAll();
// Small delay to ensure all deferred events have fired before resuming // Small delay to ensure all deferred events have fired before resuming
setTimeout(() => resumeBroadcasts(), 100); setTimeout(() => resumeBroadcasts(), 100);
@@ -240,13 +278,15 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
const cx = (minX + maxX) / 2; const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2; const cy = (minY + maxY) / 2;
canvas.setViewportTransform([ const vpt: [number, number, number, number, number, number] = [
zoom, 0, 0, zoom, zoom, 0, 0, zoom,
canvasW / 2 - cx * zoom, canvasW / 2 - cx * zoom,
canvasH / 2 - cy * zoom, canvasH / 2 - cy * zoom,
]); ];
canvas.setViewportTransform(vpt);
canvas.requestRenderAll(); canvas.requestRenderAll();
}, []); if (boardId) saveViewport(boardId, vpt);
}, [boardId]);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
getCanvas: () => fabricRef.current, getCanvas: () => fabricRef.current,
@@ -258,8 +298,12 @@ const FabricCanvas = forwardRef<FabricCanvasHandle, FabricCanvasProps>(
const center = canvas.getCenterPoint(); const center = canvas.getCenterPoint();
canvas.zoomToPoint(center, Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))); canvas.zoomToPoint(center, Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)));
canvas.requestRenderAll(); canvas.requestRenderAll();
if (boardId) {
const vpt = canvas.viewportTransform;
if (vpt) saveViewport(boardId, [...vpt]);
}
}, },
}), [fitAll]); }), [fitAll, boardId]);
return ( return (
<div <div
+1
View File
@@ -735,6 +735,7 @@ export default function Editor({ isPublicView }: EditorProps) {
ref={canvasRef} ref={canvasRef}
canvasState={canvasState} canvasState={canvasState}
currentTool={activeTool} currentTool={activeTool}
boardId={resolvedBoardId}
onChange={onCanvasChange} onChange={onCanvasChange}
/> />
<UserCursors <UserCursors