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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user