feat(canvas): add theme-aware background and zoom-to-fit-content

- canvas background follows dark/light theme via MutationObserver
- add zoomToFitContent() to center viewport on document content
- call zoomToFitContent after canvas init, new document, and open file
- refactor top-bar theme init to useEffect for SSR hydration safety
This commit is contained in:
Fini 2026-02-19 01:21:08 +08:00
parent a49018b589
commit a1256cc4fe
4 changed files with 134 additions and 23 deletions

View file

@ -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

View file

@ -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<HTMLCanvasElement | null>,
@ -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 <html> 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

View file

@ -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())
}
})
}

View file

@ -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<string, ToolType> = {
@ -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())
}
})
}