feat(editor): add top bar with file operations, theme toggle and fullscreen

- new TopBar component: layer panel toggle, new/open/save, centered filename, theme switch, fullscreen
- add layerPanelOpen state and toggleLayerPanel action to canvas-store
- add light theme CSS variables in styles.css, persisted via localStorage
- conditionally render LayerPanel based on toggle state
This commit is contained in:
Fini 2026-02-18 23:42:44 +08:00
parent 28b42e2abf
commit 0d0be80129
4 changed files with 264 additions and 2 deletions

View file

@ -1,5 +1,6 @@
import { lazy, Suspense, useState, useCallback, useEffect } from 'react'
import { TooltipProvider } from '@/components/ui/tooltip'
import TopBar from './top-bar'
import Toolbar from './toolbar'
import StatusBar from './status-bar'
import LayerPanel from '@/components/panels/layer-panel'
@ -9,12 +10,15 @@ import CodePanel from '@/components/panels/code-panel'
import ExportDialog from '@/components/shared/export-dialog'
import SaveDialog from '@/components/shared/save-dialog'
import { useAIStore } from '@/stores/ai-store'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
const FabricCanvas = lazy(() => import('@/canvas/fabric-canvas'))
export default function EditorLayout() {
const toggleMinimize = useAIStore((s) => s.toggleMinimize)
const hasSelection = useCanvasStore((s) => s.selection.activeId !== null)
const layerPanelOpen = useCanvasStore((s) => s.layerPanelOpen)
const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen)
const closeSaveDialog = useCallback(() => {
useDocumentStore.getState().setSaveDialogOpen(false)
@ -62,9 +66,10 @@ export default function EditorLayout() {
return (
<TooltipProvider delayDuration={300}>
<div className="h-screen flex flex-col bg-background">
<TopBar />
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex overflow-hidden">
<LayerPanel />
{layerPanelOpen && <LayerPanel />}
<div className="flex-1 flex flex-col min-w-0 relative">
<Suspense
fallback={
@ -90,7 +95,7 @@ export default function EditorLayout() {
{/* Expanded AI panel (floating, draggable) */}
<AIChatPanel />
</div>
<PropertyPanel />
{hasSelection && <PropertyPanel />}
</div>
{codePanelOpen && <CodePanel onClose={() => setCodePanelOpen(false)} />}
</div>

View file

@ -0,0 +1,225 @@
import { useCallback, useEffect, useState } from 'react'
import {
PanelLeft,
FilePlus,
FolderOpen,
Save,
Sun,
Moon,
Maximize,
Minimize,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvas-store'
import { useDocumentStore } from '@/stores/document-store'
import {
supportsFileSystemAccess,
writeToFileHandle,
saveDocumentAs,
downloadDocument,
openDocumentFS,
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')
}
}
export default function TopBar() {
const toggleLayerPanel = useCanvasStore((s) => s.toggleLayerPanel)
const layerPanelOpen = useCanvasStore((s) => s.layerPanelOpen)
const fileName = useDocumentStore((s) => s.fileName)
const isDirty = useDocumentStore((s) => s.isDirty)
const [theme, setTheme] = useState<'dark' | 'light'>(getInitialTheme)
const [isFullscreen, setIsFullscreen] = useState(false)
// Listen to fullscreen changes
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement)
document.addEventListener('fullscreenchange', handler)
return () => document.removeEventListener('fullscreenchange', handler)
}, [])
const toggleTheme = useCallback(() => {
const next = theme === 'dark' ? 'light' : 'dark'
if (next === 'light') {
document.documentElement.classList.add('light')
} else {
document.documentElement.classList.remove('light')
}
setTheme(next)
try {
localStorage.setItem('openpencil-theme', next)
} catch {
// ignore
}
}, [theme])
const toggleFullscreen = useCallback(() => {
if (document.fullscreenElement) {
document.exitFullscreen()
} else {
document.documentElement.requestFullscreen()
}
}, [])
const handleNew = useCallback(() => {
useDocumentStore.getState().newDocument()
}, [])
const handleSave = useCallback(() => {
syncCanvasPositionsToStore()
const store = useDocumentStore.getState()
const { document: doc, fileName: fn, fileHandle } = store
if (fileHandle) {
writeToFileHandle(fileHandle, doc).then(() => store.markClean())
} else if (supportsFileSystemAccess()) {
saveDocumentAs(doc, fn ?? 'untitled.pen').then((result) => {
if (result) {
useDocumentStore.setState({
fileName: result.fileName,
fileHandle: result.handle,
isDirty: false,
})
}
})
} else if (fn) {
downloadDocument(doc, fn)
store.markClean()
} else {
store.setSaveDialogOpen(true)
}
}, [])
const handleOpen = useCallback(() => {
if (supportsFileSystemAccess()) {
openDocumentFS().then((result) => {
if (result) {
useDocumentStore
.getState()
.loadDocument(result.doc, result.fileName, result.handle)
}
})
} else {
openDocument().then((result) => {
if (result) {
useDocumentStore.getState().loadDocument(result.doc, result.fileName)
}
})
}
}, [])
const displayName = fileName ?? 'Untitled'
return (
<div className="h-10 bg-card border-b border-border flex items-center px-2 shrink-0 select-none">
{/* Left section */}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={toggleLayerPanel}
className={layerPanelOpen ? 'text-foreground' : 'text-muted-foreground'}
>
<PanelLeft size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{layerPanelOpen ? 'Hide layers' : 'Show layers'}
</TooltipContent>
</Tooltip>
<div className="w-px h-4 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={handleNew}>
<FilePlus size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">New document</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={handleOpen}>
<FolderOpen size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Open</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={handleSave}>
<Save size={16} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Save</TooltipContent>
</Tooltip>
</div>
{/* Center section — file name */}
<div className="flex-1 flex items-center justify-center min-w-0">
<span className="text-xs text-foreground truncate">
{displayName}
</span>
{isDirty && (
<span className="text-xs text-muted-foreground ml-1.5">
Edited
</span>
)}
</div>
{/* Right section */}
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={toggleTheme}>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-sm" onClick={toggleFullscreen}>
{isFullscreen ? <Minimize size={16} /> : <Maximize size={16} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
</TooltipContent>
</Tooltip>
</div>
</div>
)
}

View file

@ -15,6 +15,7 @@ interface CanvasStoreState {
interaction: CanvasInteraction
fabricCanvas: Canvas | null
clipboard: PenNode[]
layerPanelOpen: boolean
setActiveTool: (tool: ToolType) => void
setZoom: (zoom: number) => void
@ -24,6 +25,7 @@ interface CanvasStoreState {
setInteraction: (partial: Partial<CanvasInteraction>) => void
setFabricCanvas: (canvas: Canvas | null) => void
setClipboard: (nodes: PenNode[]) => void
toggleLayerPanel: () => void
}
export const useCanvasStore = create<CanvasStoreState>((set) => ({
@ -38,6 +40,7 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
},
fabricCanvas: null,
clipboard: [],
layerPanelOpen: true,
setActiveTool: (tool) => set({ activeTool: tool }),
@ -59,4 +62,6 @@ export const useCanvasStore = create<CanvasStoreState>((set) => ({
setFabricCanvas: (fabricCanvas) => set({ fabricCanvas }),
setClipboard: (clipboard) => set({ clipboard }),
toggleLayerPanel: () => set((s) => ({ layerPanelOpen: !s.layerPanelOpen })),
}))

View file

@ -61,6 +61,33 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
}
:root.light {
--background: oklch(0.985 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.623 0.214 259);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.94 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.87 0 0);
--input: oklch(0.87 0 0);
--ring: oklch(0.623 0.214 259);
--sidebar: oklch(0.97 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-border: oklch(0.87 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
}
body {
@apply m-0 bg-background text-foreground antialiased;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",