diff --git a/src/canvas/canvas-constants.ts b/src/canvas/canvas-constants.ts index 531f8726f..665de70c0 100644 --- a/src/canvas/canvas-constants.ts +++ b/src/canvas/canvas-constants.ts @@ -5,7 +5,15 @@ export const SNAP_THRESHOLD = 5 export const DEFAULT_FILL = '#d1d5db' export const DEFAULT_STROKE = '#374151' export const DEFAULT_STROKE_WIDTH = 1 -export const CANVAS_BACKGROUND = '#f5f5f5' +export const CANVAS_BACKGROUND_LIGHT = '#e5e5e5' +export const CANVAS_BACKGROUND_DARK = '#1a1a1a' + +export function getCanvasBackground(): string { + if (typeof document === 'undefined') return CANVAS_BACKGROUND_DARK + return document.documentElement.classList.contains('light') + ? CANVAS_BACKGROUND_LIGHT + : CANVAS_BACKGROUND_DARK +} export const SELECTION_BLUE = '#0d99ff' // Smart guides diff --git a/src/canvas/use-fabric-canvas.ts b/src/canvas/use-fabric-canvas.ts index 3c0ba73a7..7acc13861 100644 --- a/src/canvas/use-fabric-canvas.ts +++ b/src/canvas/use-fabric-canvas.ts @@ -1,7 +1,95 @@ import { useEffect, useRef, type RefObject } from 'react' import * as fabric from 'fabric' import { useCanvasStore } from '@/stores/canvas-store' -import { CANVAS_BACKGROUND, SELECTION_BLUE } from './canvas-constants' +import { useDocumentStore } from '@/stores/document-store' +import type { PenNode } from '@/types/pen' +import { getCanvasBackground, SELECTION_BLUE, MIN_ZOOM, MAX_ZOOM } from './canvas-constants' + +const FIT_PADDING = 64 + +function nodeSize(v: unknown): number { + if (typeof v === 'number') return v + if (typeof v === 'string') { + const m = v.match(/\((\d+(?:\.\d+)?)\)/) + if (m) return parseFloat(m[1]) + const n = parseFloat(v) + if (!isNaN(n)) return n + } + return 0 +} + +/** Compute the bounding box of all document nodes (recursive). */ +function computeDocBounds(nodes: PenNode[], ox = 0, oy = 0) { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const node of nodes) { + const nx = (node.x ?? 0) + ox + const ny = (node.y ?? 0) + oy + const nw = 'width' in node ? nodeSize(node.width) : 0 + const nh = 'height' in node ? nodeSize(node.height) : 0 + + minX = Math.min(minX, nx) + minY = Math.min(minY, ny) + maxX = Math.max(maxX, nx + (nw || 100)) + maxY = Math.max(maxY, ny + (nh || 100)) + + if ('children' in node && node.children && node.children.length > 0) { + const child = computeDocBounds(node.children, nx, ny) + minX = Math.min(minX, child.minX) + minY = Math.min(minY, child.minY) + maxX = Math.max(maxX, child.maxX) + maxY = Math.max(maxY, child.maxY) + } + } + + return { minX, minY, maxX, maxY } +} + +/** + * Zoom and pan so all document content fits in the visible canvas area + * with some padding. Call after canvas init, newDocument(), or loadDocument(). + */ +export function zoomToFitContent() { + const canvas = useCanvasStore.getState().fabricCanvas + if (!canvas) return + + const children = useDocumentStore.getState().document.children + if (children.length === 0) return + + const { minX, minY, maxX, maxY } = computeDocBounds(children) + if (!isFinite(minX)) return + + const contentW = maxX - minX + const contentH = maxY - minY + const cw = canvas.getWidth() + const ch = canvas.getHeight() + + // Calculate zoom to fit content with padding + const scaleX = (cw - FIT_PADDING * 2) / contentW + const scaleY = (ch - FIT_PADDING * 2) / contentH + let zoom = Math.min(scaleX, scaleY) + zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)) + // Don't zoom in beyond 1x for small content + zoom = Math.min(zoom, 1) + + const centerX = (minX + maxX) / 2 + const centerY = (minY + maxY) / 2 + const panX = cw / 2 - centerX * zoom + const panY = ch / 2 - centerY * zoom + + const vpt = canvas.viewportTransform + vpt[0] = zoom + vpt[3] = zoom + vpt[4] = panX + vpt[5] = panY + canvas.setViewportTransform(vpt) + useCanvasStore.getState().setZoom(zoom) + useCanvasStore.getState().setPan(panX, panY) + canvas.requestRenderAll() +} export function useFabricCanvas( canvasRef: RefObject, @@ -19,7 +107,7 @@ export function useFabricCanvas( const canvas = new fabric.Canvas(el, { width: container.clientWidth, height: container.clientHeight, - backgroundColor: CANVAS_BACKGROUND, + backgroundColor: getCanvasBackground(), selection: true, preserveObjectStacking: true, stopContextMenu: true, @@ -34,6 +122,9 @@ export function useFabricCanvas( useCanvasStore.getState().setFabricCanvas(canvas) canvas.requestRenderAll() + // Center viewport on the default frame after a tick (sync needs to run first) + requestAnimationFrame(() => zoomToFitContent()) + // Resize observer const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { @@ -44,8 +135,19 @@ export function useFabricCanvas( }) resizeObserver.observe(container) + // Watch theme changes on class to update canvas background + const themeObserver = new MutationObserver(() => { + canvas.backgroundColor = getCanvasBackground() + canvas.requestRenderAll() + }) + themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }) + return () => { resizeObserver.disconnect() + themeObserver.disconnect() useCanvasStore.getState().setFabricCanvas(null) canvas.dispose() initialized.current = false diff --git a/src/components/editor/top-bar.tsx b/src/components/editor/top-bar.tsx index 9d7689914..2db4bb5d5 100644 --- a/src/components/editor/top-bar.tsx +++ b/src/components/editor/top-bar.tsx @@ -26,25 +26,7 @@ import { openDocument, } from '@/utils/file-operations' import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync' - -function getInitialTheme(): 'dark' | 'light' { - if (typeof window === 'undefined') return 'dark' - try { - const saved = localStorage.getItem('openpencil-theme') - if (saved === 'light' || saved === 'dark') return saved - } catch { - // ignore - } - return 'dark' -} - -// Apply saved theme before first render to avoid flash -if (typeof window !== 'undefined') { - const saved = getInitialTheme() - if (saved === 'light') { - document.documentElement.classList.add('light') - } -} +import { zoomToFitContent } from '@/canvas/use-fabric-canvas' export default function TopBar() { const toggleLayerPanel = useCanvasStore((s) => s.toggleLayerPanel) @@ -52,9 +34,22 @@ export default function TopBar() { const fileName = useDocumentStore((s) => s.fileName) const isDirty = useDocumentStore((s) => s.isDirty) - const [theme, setTheme] = useState<'dark' | 'light'>(getInitialTheme) + const [theme, setTheme] = useState<'dark' | 'light'>('dark') const [isFullscreen, setIsFullscreen] = useState(false) + // Restore saved theme after hydration + useEffect(() => { + try { + const saved = localStorage.getItem('openpencil-theme') + if (saved === 'light') { + document.documentElement.classList.add('light') + setTheme('light') + } + } catch { + // ignore + } + }, []) + // Listen to fullscreen changes useEffect(() => { const handler = () => setIsFullscreen(!!document.fullscreenElement) @@ -87,6 +82,7 @@ export default function TopBar() { const handleNew = useCallback(() => { useDocumentStore.getState().newDocument() + requestAnimationFrame(() => zoomToFitContent()) }, []) const handleSave = useCallback(() => { @@ -121,12 +117,14 @@ export default function TopBar() { useDocumentStore .getState() .loadDocument(result.doc, result.fileName, result.handle) + requestAnimationFrame(() => zoomToFitContent()) } }) } else { openDocument().then((result) => { if (result) { useDocumentStore.getState().loadDocument(result.doc, result.fileName) + requestAnimationFrame(() => zoomToFitContent()) } }) } diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index 497130e86..46964b732 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -13,6 +13,7 @@ import { openDocument, } from '@/utils/file-operations' import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync' +import { zoomToFitContent } from '@/canvas/use-fabric-canvas' import type { ToolType } from '@/types/canvas' const TOOL_KEYS: Record = { @@ -171,6 +172,7 @@ export function useKeyboardShortcuts() { useDocumentStore .getState() .loadDocument(result.doc, result.fileName, result.handle) + requestAnimationFrame(() => zoomToFitContent()) } }) } else { @@ -179,6 +181,7 @@ export function useKeyboardShortcuts() { useDocumentStore .getState() .loadDocument(result.doc, result.fileName) + requestAnimationFrame(() => zoomToFitContent()) } }) }