mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
28b42e2abf
commit
0d0be80129
4 changed files with 264 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
225
src/components/editor/top-bar.tsx
Normal file
225
src/components/editor/top-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })),
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue