diff --git a/src/components/image-editor/MenuBar.tsx b/src/components/image-editor/MenuBar.tsx
new file mode 100644
index 0000000..4df9287
--- /dev/null
+++ b/src/components/image-editor/MenuBar.tsx
@@ -0,0 +1,258 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { useTranslation } from 'react-i18next'
+
+/**
+ * PS-style menu bar — File / Edit / Image / Layer / View menus across the
+ * top, each with a dropdown. Most items here are "wired to existing actions
+ * where they exist, otherwise no-op-with-toast" — the bar is primarily a
+ * familiar structural element for users coming from PS.
+ *
+ * Items the editor currently supports get a real callback; the rest are
+ * disabled (rendered greyed out) so the user can see the surface without
+ * being misled.
+ */
+export type MenuAction = {
+ id: string
+ label: string
+ shortcut?: string
+ onClick?: () => void
+ disabled?: boolean
+}
+export type MenuSection = MenuAction[] | { sep: true }
+
+type MenuDef = {
+ id: string
+ label: string
+ sections: (MenuAction[] | { sep: true })[]
+}
+
+type Props = {
+ /** Action handlers — the editor wires only what it implements. */
+ handlers: {
+ open?: () => void
+ save?: () => void
+ saveAs?: () => void
+ download?: () => void
+ exportPng?: () => void
+ exportJpeg?: () => void
+ exportWebp?: () => void
+ undo?: () => void
+ redo?: () => void
+ canUndo?: boolean
+ canRedo?: boolean
+ rotate90?: () => void
+ flipH?: () => void
+ flipV?: () => void
+ duplicateLayer?: () => void
+ deleteLayer?: () => void
+ zoomIn?: () => void
+ zoomOut?: () => void
+ zoomFit?: () => void
+ toggleFocus?: () => void
+ }
+}
+
+export function MenuBar({ handlers }: Props) {
+ const { t } = useTranslation()
+ const [openIdx, setOpenIdx] = useState(-1)
+
+ // ESC closes the menu. Click outside closes via .pf-menu-backdrop.
+ useEffect(() => {
+ if (openIdx < 0) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setOpenIdx(-1)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [openIdx])
+
+ const menus: MenuDef[] = [
+ {
+ id: 'file',
+ label: t('pages.imageEditor.menu.file'),
+ sections: [
+ [
+ { id: 'open', label: t('pages.imageEditor.menu.open'), shortcut: '⌘O', onClick: handlers.open },
+ {
+ id: 'save',
+ label: t('pages.imageEditor.menu.saveProject'),
+ shortcut: '⌘S',
+ onClick: handlers.save,
+ },
+ ],
+ { sep: true },
+ [
+ { id: 'png', label: t('pages.imageEditor.menu.exportPng'), shortcut: '⌘E', onClick: handlers.exportPng ?? handlers.download },
+ { id: 'jpg', label: t('pages.imageEditor.menu.exportJpeg'), onClick: handlers.exportJpeg },
+ { id: 'webp', label: t('pages.imageEditor.menu.exportWebp'), onClick: handlers.exportWebp },
+ ],
+ ],
+ },
+ {
+ id: 'edit',
+ label: t('pages.imageEditor.menu.edit'),
+ sections: [
+ [
+ {
+ id: 'undo',
+ label: t('pages.imageEditor.menu.undo'),
+ shortcut: '⌘Z',
+ onClick: handlers.undo,
+ disabled: !handlers.canUndo,
+ },
+ {
+ id: 'redo',
+ label: t('pages.imageEditor.menu.redo'),
+ shortcut: '⇧⌘Z',
+ onClick: handlers.redo,
+ disabled: !handlers.canRedo,
+ },
+ ],
+ ],
+ },
+ {
+ id: 'image',
+ label: t('pages.imageEditor.menu.image'),
+ sections: [
+ [
+ { id: 'rot90', label: t('pages.imageEditor.menu.rotate90'), onClick: handlers.rotate90 },
+ { id: 'flipH', label: t('pages.imageEditor.menu.flipH'), onClick: handlers.flipH },
+ { id: 'flipV', label: t('pages.imageEditor.menu.flipV'), onClick: handlers.flipV },
+ ],
+ ],
+ },
+ {
+ id: 'layer',
+ label: t('pages.imageEditor.menu.layer'),
+ sections: [
+ [
+ {
+ id: 'dup',
+ label: t('pages.imageEditor.menu.duplicateLayer'),
+ shortcut: '⌘J',
+ onClick: handlers.duplicateLayer,
+ },
+ {
+ id: 'delLayer',
+ label: t('pages.imageEditor.menu.deleteLayer'),
+ shortcut: '⌫',
+ onClick: handlers.deleteLayer,
+ },
+ ],
+ ],
+ },
+ {
+ id: 'view',
+ label: t('pages.imageEditor.menu.view'),
+ sections: [
+ [
+ { id: 'zin', label: t('pages.imageEditor.menu.zoomIn'), shortcut: '⌘+', onClick: handlers.zoomIn },
+ { id: 'zout', label: t('pages.imageEditor.menu.zoomOut'), shortcut: '⌘-', onClick: handlers.zoomOut },
+ { id: 'fit', label: t('pages.imageEditor.menu.zoomFit'), shortcut: '⌘0', onClick: handlers.zoomFit },
+ ],
+ { sep: true },
+ [
+ {
+ id: 'focus',
+ label: t('pages.imageEditor.menu.toggleFocus'),
+ shortcut: 'F',
+ onClick: handlers.toggleFocus,
+ },
+ ],
+ ],
+ },
+ ]
+
+ return (
+
+
+ PixelForge
+
+ {menus.map((m, i) => (
+
setOpenIdx((cur) => (cur === i ? -1 : i))}
+ onHover={() => {
+ if (openIdx >= 0 && openIdx !== i) setOpenIdx(i)
+ }}
+ >
+ {openIdx === i && (
+ setOpenIdx(-1)}
+ />
+ )}
+
+ ))}
+ {openIdx >= 0 && (
+
setOpenIdx(-1)}
+ aria-hidden
+ />
+ )}
+
+ )
+}
+
+function MenuButton({
+ label,
+ open,
+ onToggle,
+ onHover,
+ children,
+}: {
+ label: string
+ open: boolean
+ onToggle: () => void
+ onHover: () => void
+ children: ReactNode
+}) {
+ const ref = useRef
(null)
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+function MenuDropdown({
+ sections,
+ onClose,
+}: {
+ sections: (MenuAction[] | { sep: true })[]
+ onClose: () => void
+}) {
+ return (
+ e.stopPropagation()}>
+ {sections.flatMap((sec, i) => {
+ if ('sep' in sec) return [
]
+ return sec.map((it) => (
+
{
+ e.stopPropagation()
+ if (it.disabled) return
+ it.onClick?.()
+ onClose()
+ }}
+ >
+
+ {it.label}
+ {it.shortcut ? {it.shortcut} : }
+
+ ))
+ })}
+
+ )
+}
diff --git a/src/components/image-editor/OptionsBar.tsx b/src/components/image-editor/OptionsBar.tsx
new file mode 100644
index 0000000..a22723d
--- /dev/null
+++ b/src/components/image-editor/OptionsBar.tsx
@@ -0,0 +1,225 @@
+import { useTranslation } from 'react-i18next'
+import type { Tool } from '@/lib/image-editor/types'
+
+type Props = {
+ tool: Tool
+ fgColor: string
+ setFgColor: (c: string) => void
+ strokeWidth: number
+ setStrokeWidth: (n: number) => void
+ /** Show "applies to all in fly-out group" notice for stub tools. */
+ isStubTool: boolean
+ /** Re-fired with the toast pattern when a stub tool was clicked. */
+ stubMessage?: string
+ /** True when an applied crop is in state — surfaces "Clear crop" button. */
+ hasActiveCrop?: boolean
+ onClearCrop?: () => void
+}
+
+/**
+ * Options bar — sits below the menu bar and shows context-sensitive controls
+ * for the active tool. Each tool gets its own variant: brushes show stroke
+ * width + color, crop shows the apply hint, marquee shows feather/anti-alias
+ * stubs, etc. For tools that aren't yet implemented we render a banner that
+ * tells the user the palette button is a placeholder.
+ */
+export function OptionsBar({
+ tool,
+ fgColor,
+ setFgColor,
+ strokeWidth,
+ setStrokeWidth,
+ isStubTool,
+ stubMessage,
+ hasActiveCrop,
+ onClearCrop,
+}: Props) {
+ const { t } = useTranslation()
+
+ if (isStubTool) {
+ return (
+
+
+
+ {stubMessage ?? t('pages.imageEditor.toolStubHint', { tool: t(`pages.imageEditor.tool.${tool}`) })}
+
+
+
+ )
+ }
+
+ // Brush / pencil / eraser — stroke width + color.
+ if (tool === 'brush' || tool === 'eraser') {
+ return (
+
+ )
+ }
+
+ // Shape tools — stroke width + color.
+ if (tool === 'rect' || tool === 'ellipse' || tool === 'line' || tool === 'arrow') {
+ return (
+
+ )
+ }
+
+ if (tool === 'crop') {
+ return (
+
+
+
+ {t('pages.imageEditor.cropPendingHint')}
+
+
+ {hasActiveCrop && (
+
+
+ {t('pages.imageEditor.cropClear')}
+
+
+ )}
+
+ )
+ }
+
+ if (tool === 'zoom') {
+ return (
+
+
+
+ {t('pages.imageEditor.zoomToolHint')}
+
+
+
+ )
+ }
+
+ if (tool === 'eyedropper') {
+ return (
+
+
+
+ {t('pages.imageEditor.eyedropperHint')}
+
+
+
+ )
+ }
+
+ if (tool === 'text') {
+ return (
+
+
+ {t('pages.imageEditor.color')}:
+ setFgColor(e.target.value)}
+ style={{
+ width: 22,
+ height: 22,
+ padding: 0,
+ border: '1px solid var(--pf-line)',
+ background: 'transparent',
+ borderRadius: 3,
+ cursor: 'pointer',
+ }}
+ />
+
+
+
+ {t('pages.imageEditor.textToolHint')}
+
+
+
+ )
+ }
+
+ if (tool === 'mask' || tool === 'mosaic') {
+ return (
+
+
+
+ {t('pages.imageEditor.toolHint', { tool: t(`pages.imageEditor.tool.${tool}`) })}
+
+
+
+ )
+ }
+
+ // Move / select (none)
+ return (
+
+
+
+ {t('pages.imageEditor.moveToolHint')}
+
+
+
+ )
+}
diff --git a/src/components/image-editor/RightSidebar.tsx b/src/components/image-editor/RightSidebar.tsx
index 7a33c38..f0838e6 100644
--- a/src/components/image-editor/RightSidebar.tsx
+++ b/src/components/image-editor/RightSidebar.tsx
@@ -1,6 +1,5 @@
import { useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
-import { ChevronDown, ChevronRight, Layers as LayersIcon, Settings2, Sliders } from 'lucide-react'
import { AdjustPanel } from './AdjustPanel'
import { LayersPanel } from './LayersPanel'
import { PropertiesPanel } from './PropertiesPanel'
@@ -16,13 +15,16 @@ type Props = {
deleteLayer: (id: string) => void
setTransforms: (t: Transforms) => void
setAdjust: (a: Adjustments) => void
+ zoom: number
}
/**
- * Right-hand panel column. Three collapsible sections, all visible at once
- * (unlike the previous tab arrangement). Layer list on top, then Properties
- * for the currently selected layer, then Adjust (transforms + filters that
- * apply to the image background).
+ * Right column — three stacked panel groups, each with its own tab strip
+ * (PS-style). Group 1: Layers / Channels / Paths. Group 2: Properties /
+ * Info. Group 3: Adjustments / Navigator. Channels / Paths / Info /
+ * Navigator are stub-but-present so the visual completeness matches the
+ * design hand-off; the Layers / Properties / Adjustments tabs are the
+ * functional ones from the existing implementation.
*/
export function RightSidebar({
state,
@@ -34,67 +36,142 @@ export function RightSidebar({
deleteLayer,
setTransforms,
setAdjust,
+ zoom,
}: Props) {
const { t } = useTranslation()
+ const [g1, setG1] = useState<'layers' | 'channels' | 'paths'>('layers')
+ const [g2, setG2] = useState<'properties' | 'info'>('properties')
+ const [g3, setG3] = useState<'adjustments' | 'navigator'>('adjustments')
+
return (
-
- }>
-
-
+
+ setG1(id as typeof g1)}
+ >
+ {g1 === 'layers' && (
+
+
+
+ )}
+ {g1 === 'channels' && }
+ {g1 === 'paths' && }
+
- }>
-
-
+ setG2(id as typeof g2)}
+ >
+ {g2 === 'properties' && (
+
+ )}
+ {g2 === 'info' && }
+
- }>
-
-
+ setG3(id as typeof g3)}
+ >
+ {g3 === 'adjustments' && (
+
+ )}
+ {g3 === 'navigator' && }
+
)
}
-function Section({
- title,
- icon,
- defaultOpen = true,
+function PanelGroup({
+ tabs,
+ active,
+ setActive,
children,
}: {
- title: string
- icon: ReactNode
- defaultOpen?: boolean
+ tabs: { id: string; label: string }[]
+ active: string
+ setActive: (id: string) => void
children: ReactNode
}) {
- const [open, setOpen] = useState(defaultOpen)
return (
-
-
setOpen((v) => !v)}
- className="flex w-full items-center gap-2 px-2 py-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground/80 hover:text-foreground"
- >
- {open ? : }
- {icon}
- {title}
-
- {open ?
{children}
: null}
+
+
+ {tabs.map((t) => (
+
setActive(t.id)}
+ >
+ {t.label}
+
+ ))}
+
+
+ {children}
+
+ )
+}
+
+function StubPanel({ msg }: { msg: string }) {
+ return (
+
+ {msg}
+
+ )
+}
+
+function NavigatorStub({ zoom }: { zoom: number }) {
+ const { t } = useTranslation()
+ return (
+
+
+
+ {t('pages.imageEditor.zoom')}: {Math.round(zoom * 100)}%
+
)
}
diff --git a/src/components/image-editor/StatusBar.tsx b/src/components/image-editor/StatusBar.tsx
index 6104c3d..91d40e9 100644
--- a/src/components/image-editor/StatusBar.tsx
+++ b/src/components/image-editor/StatusBar.tsx
@@ -1,6 +1,4 @@
import { useTranslation } from 'react-i18next'
-import { Maximize, Minus, Plus } from 'lucide-react'
-import { Button } from '@/components/ui/button'
import type { Tool } from '@/lib/image-editor/types'
type Props = {
@@ -14,75 +12,27 @@ type Props = {
}
/**
- * PS-style status bar at the bottom of the editor:
- *
· + - fit ·
- *
- * Mostly informational; the zoom buttons are the only interactive bits and
- * mirror the keyboard shortcuts. Designed to be unobtrusive (~28px tall).
+ * PixelForge status bar — zoom input + doc dimensions on the left, hint text
+ * on the right. Compact (var(--pf-status-h)).
*/
-export function StatusBar({
- width,
- height,
- zoom,
- onZoomIn,
- onZoomOut,
- onZoomReset,
- tool,
-}: Props) {
+export function StatusBar({ width, height, zoom, onZoomReset, tool }: Props) {
const { t } = useTranslation()
const zoomPct = Math.round(zoom * 100)
return (
-
-
+
+
+
{width} × {height}
-
-
-
-
-
-
- {zoomPct}%
-
-
-
-
-
-
-
-
-
-
- {t('pages.imageEditor.currentTool')}:{' '}
- {t(`pages.imageEditor.tool.${tool}`)}
+
+ {t('pages.imageEditor.statusTip', { tool: t(`pages.imageEditor.tool.${tool}`) })}
)
}
-
-function Separator() {
- return
-}
diff --git a/src/components/image-editor/ToolsPalette.tsx b/src/components/image-editor/ToolsPalette.tsx
index 89f1336..9bb91a8 100644
--- a/src/components/image-editor/ToolsPalette.tsx
+++ b/src/components/image-editor/ToolsPalette.tsx
@@ -5,35 +5,170 @@ import {
Brush,
Circle,
Crop,
+ Droplet,
Eraser,
Frame,
- Minus,
+ Hand,
+ Lasso,
MousePointer2,
+ PaintBucket,
+ PenTool,
Pipette,
- RotateCcw,
+ RotateCw,
+ Ruler,
+ ScanLine,
Search,
+ Spline,
Square,
+ SquareDashed,
Squircle,
+ Sun,
Type,
+ Wand2,
} from 'lucide-react'
import type { Tool } from '@/lib/image-editor/types'
-const TOOLS: { tool: Tool; icon: ReactNode; labelKey: string; key?: string }[] = [
- { tool: 'none', icon: , labelKey: 'pages.imageEditor.tool.none', key: 'V' },
- { tool: 'rect', icon: , labelKey: 'pages.imageEditor.tool.rect', key: 'M' },
- { tool: 'ellipse', icon: , labelKey: 'pages.imageEditor.tool.ellipse', key: 'O' },
- { tool: 'line', icon: , labelKey: 'pages.imageEditor.tool.line', key: 'L' },
- { tool: 'arrow', icon: , labelKey: 'pages.imageEditor.tool.arrow', key: 'A' },
- { tool: 'text', icon: , labelKey: 'pages.imageEditor.tool.text', key: 'T' },
- { tool: 'mosaic', icon: , labelKey: 'pages.imageEditor.tool.mosaic' },
- { tool: 'brush', icon: , labelKey: 'pages.imageEditor.tool.brush', key: 'B' },
- { tool: 'eraser', icon: , labelKey: 'pages.imageEditor.tool.eraser', key: 'E' },
- { tool: 'mask', icon: , labelKey: 'pages.imageEditor.tool.mask' },
- { tool: 'crop', icon: , labelKey: 'pages.imageEditor.tool.crop', key: 'C' },
- { tool: 'eyedropper', icon: , labelKey: 'pages.imageEditor.tool.eyedropper', key: 'I' },
- { tool: 'zoom', icon: , labelKey: 'pages.imageEditor.tool.zoom', key: 'Z' },
+type ToolDef = {
+ id: Tool
+ icon: ReactNode
+ labelKey: string
+ shortcut?: string
+ /** True = render but not yet implemented; click shows a "coming soon" toast. */
+ stub?: boolean
+}
+
+/**
+ * Vertical tool rail. Tools are grouped by function with thin separators
+ * mirroring PS's left-rail organization. Functional tools have full
+ * behavior; stub tools render the icon + tooltip but call `onStubClick` to
+ * surface a "not yet implemented" message instead of silently swapping the
+ * tool to a no-op state.
+ */
+const GROUPS: ToolDef[][] = [
+ // 1. Selection / move group
+ [
+ { id: 'none', icon: , labelKey: 'pages.imageEditor.tool.none', shortcut: 'V' },
+ {
+ id: 'marquee',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.marquee',
+ shortcut: 'M',
+ stub: true,
+ },
+ {
+ id: 'lasso',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.lasso',
+ shortcut: 'L',
+ stub: true,
+ },
+ {
+ id: 'wand',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.wand',
+ shortcut: 'W',
+ stub: true,
+ },
+ { id: 'crop', icon: , labelKey: 'pages.imageEditor.tool.crop', shortcut: 'C' },
+ {
+ id: 'eyedropper',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.eyedropper',
+ shortcut: 'I',
+ },
+ ],
+ // 2. Painting group
+ [
+ {
+ id: 'spotHeal',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.spotHeal',
+ shortcut: 'J',
+ stub: true,
+ },
+ { id: 'brush', icon: , labelKey: 'pages.imageEditor.tool.brush', shortcut: 'B' },
+ {
+ id: 'stamp',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.stamp',
+ shortcut: 'S',
+ stub: true,
+ },
+ {
+ id: 'historyBrush',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.historyBrush',
+ shortcut: 'Y',
+ stub: true,
+ },
+ { id: 'eraser', icon: , labelKey: 'pages.imageEditor.tool.eraser', shortcut: 'E' },
+ {
+ id: 'gradient',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.gradient',
+ shortcut: 'G',
+ stub: true,
+ },
+ {
+ id: 'bucket',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.bucket',
+ shortcut: 'G',
+ stub: true,
+ },
+ {
+ id: 'dodge',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.dodge',
+ shortcut: 'O',
+ stub: true,
+ },
+ ],
+ // 3. Vector / type group
+ [
+ {
+ id: 'pen',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.pen',
+ shortcut: 'P',
+ stub: true,
+ },
+ { id: 'text', icon: , labelKey: 'pages.imageEditor.tool.text', shortcut: 'T' },
+ { id: 'rect', icon: , labelKey: 'pages.imageEditor.tool.rect', shortcut: 'U' },
+ { id: 'ellipse', icon: , labelKey: 'pages.imageEditor.tool.ellipse', shortcut: 'U' },
+ {
+ id: 'line',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.line',
+ shortcut: 'U',
+ },
+ {
+ id: 'arrow',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.arrow',
+ },
+ ],
+ // 4. Annotation / mask
+ [
+ { id: 'mask', icon: , labelKey: 'pages.imageEditor.tool.mask' },
+ { id: 'mosaic', icon: , labelKey: 'pages.imageEditor.tool.mosaic' },
+ ],
+ // 5. Navigation
+ [
+ {
+ id: 'hand',
+ icon: ,
+ labelKey: 'pages.imageEditor.tool.hand',
+ shortcut: 'H',
+ stub: true,
+ },
+ { id: 'zoom', icon: , labelKey: 'pages.imageEditor.tool.zoom', shortcut: 'Z' },
+ ],
]
+// (STUB_TOOLS lives in `./tool-meta.ts` — keeping non-component exports out of
+// this file so react-refresh stays happy.)
+
type Props = {
tool: Tool
setTool: (t: Tool) => void
@@ -43,16 +178,9 @@ type Props = {
setBgColor: (c: string) => void
swapColors: () => void
resetColors: () => void
- strokeWidth: number
- setStrokeWidth: (n: number) => void
+ onStubClick: (toolName: string) => void
}
-/**
- * Vertical PS-style tools palette: each tool is a 36px icon button.
- * Below the tools: foreground/background color squares (PS-classic
- * stacked layout — fg in front, bg behind), with X (swap) and D
- * (reset to black/white) icon buttons. Then a stroke-width slider.
- */
export function ToolsPalette({
tool,
setTool,
@@ -62,96 +190,62 @@ export function ToolsPalette({
setBgColor,
swapColors,
resetColors,
- strokeWidth,
- setStrokeWidth,
+ onStubClick,
}: Props) {
const { t } = useTranslation()
return (
-
- {TOOLS.map(({ tool: tToolKey, icon, labelKey, key }) => {
- const active = tool === tToolKey
- return (
-
setTool(active ? 'none' : tToolKey)}
- title={key ? `${t(labelKey)} (${key})` : t(labelKey)}
- className={`flex h-9 w-9 items-center justify-center rounded transition-colors ${
- active
- ? 'bg-accent text-accent-foreground'
- : 'text-muted-foreground hover:bg-accent/40 hover:text-foreground'
- }`}
- >
- {icon}
-
- )
- })}
+
+ {GROUPS.map((group, gi) => (
+
+ {gi > 0 &&
}
+ {group.map((td) => {
+ const active = !td.stub && tool === td.id
+ return (
+
{
+ if (td.stub) onStubClick(t(td.labelKey))
+ else setTool(active ? 'none' : td.id)
+ }}
+ >
+ {td.icon}
+
+ )
+ })}
+
+ ))}
-
+
- {/* PS-classic dual color swatch: bg in back, fg in front, slightly offset. */}
-
-
- setBgColor(e.target.value)}
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
- />
+
+
+
+ setBgColor(e.target.value)} />
-
- setFgColor(e.target.value)}
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
- />
+
+ setFgColor(e.target.value)} />
-
-
-
-
-
-
-
+
+ ⇄
-
-
-
- {/* Stroke width — vertical compact slider */}
-
-
- {strokeWidth}
-
-
setStrokeWidth(Number(e.target.value))}
- title={t('pages.imageEditor.strokeWidth')}
- className="h-20 w-3 accent-primary"
- style={{ writingMode: 'vertical-lr' as unknown as undefined, direction: 'rtl' }}
- />
+
+ ▣
)
diff --git a/src/components/image-editor/TopActionBar.tsx b/src/components/image-editor/TopActionBar.tsx
deleted file mode 100644
index 50122db..0000000
--- a/src/components/image-editor/TopActionBar.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import {
- Download,
- FileJson,
- FileUp,
- ImagePlus,
- Maximize2,
- Minimize2,
- Redo2,
- Undo2,
-} from 'lucide-react'
-import { Button } from '@/components/ui/button'
-import { useRef } from 'react'
-import { FieldTooltip } from '@/components/FieldTooltip'
-import type { OutputFormat } from '@/lib/image-editor/types'
-
-type Props = {
- canUndo: boolean
- canRedo: boolean
- onUndo: () => void
- onRedo: () => void
-
- format: OutputFormat
- setFormat: (f: OutputFormat) => void
- quality: number
- setQuality: (q: number) => void
- onDownload: () => void
-
- onSaveProject: () => void
- onLoadProject: (file: File) => void
- onReplaceImage: () => void
-
- focused: boolean
- toggleFocus: () => void
-}
-
-/**
- * Top action bar (PS menu-bar-like). Houses the everyday actions: undo / redo,
- * format + quality + download, project save/load, replace image, focus mode.
- * Stays compact on narrow viewports by hiding the quality slider to overflow.
- */
-export function TopActionBar({
- canUndo,
- canRedo,
- onUndo,
- onRedo,
- format,
- setFormat,
- quality,
- setQuality,
- onDownload,
- onSaveProject,
- onLoadProject,
- onReplaceImage,
- focused,
- toggleFocus,
-}: Props) {
- const { t } = useTranslation()
- const projectInputRef = useRef
(null)
-
- return (
-
- {/* Undo / Redo */}
-
-
-
-
-
-
-
-
-
- {/* File ops */}
-
-
- {t('pages.imageEditor.replaceImage')}
-
-
-
- {t('pages.imageEditor.projectSave')}
-
-
projectInputRef.current?.click()}
- title={t('pages.imageEditor.projectLoad')}
- >
-
- {t('pages.imageEditor.projectLoad')}
-
-
{
- const f = e.target.files?.[0]
- if (f) onLoadProject(f)
- e.target.value = ''
- }}
- />
-
-
- {/* Format + quality */}
-
setFormat(e.target.value as OutputFormat)}
- className="h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground"
- >
- PNG
- JPEG
- WebP
-
- {format !== 'png' && (
-
- {t('pages.imageEditor.quality')}
- setQuality(Number(e.target.value))}
- className="w-20 accent-primary"
- title={`${quality}%`}
- />
- {quality}
-
- )}
-
-
- {t('common.download')}
-
-
-
-
- {/* Focus mode toggle */}
-
-
- {focused ? : }
-
- {focused ? t('pages.imageEditor.exitFullscreen') : t('pages.imageEditor.fullscreen')}
-
-
-
-
-
- )
-}
-
-function Separator() {
- return
-}
diff --git a/src/components/image-editor/pixelforge.css b/src/components/image-editor/pixelforge.css
new file mode 100644
index 0000000..0b13089
--- /dev/null
+++ b/src/components/image-editor/pixelforge.css
@@ -0,0 +1,441 @@
+/*
+ * PixelForge tokens — scoped to .pf-root so they don't leak into the rest of
+ * the toolbox. Mirrors the design handoff (PixelForge.html) so the editor has
+ * a distinct, denser, PS-style appearance regardless of the global theme.
+ */
+
+.pf-root {
+ --pf-hue: 220;
+ --pf-sat: 2%;
+ --pf-bg-0: hsl(var(--pf-hue), var(--pf-sat), 18%);
+ --pf-bg-1: hsl(var(--pf-hue), var(--pf-sat), 22%);
+ --pf-bg-2: hsl(var(--pf-hue), var(--pf-sat), 26%);
+ --pf-bg-3: hsl(var(--pf-hue), var(--pf-sat), 30%);
+ --pf-bg-4: hsl(var(--pf-hue), var(--pf-sat), 34%);
+ --pf-bg-canvas: hsl(var(--pf-hue), var(--pf-sat), 14%);
+ --pf-line: hsl(var(--pf-hue), var(--pf-sat), 10%);
+ --pf-line-soft: hsl(var(--pf-hue), var(--pf-sat), 38%);
+ --pf-fg: hsl(var(--pf-hue), 4%, 88%);
+ --pf-fg-mid: hsl(var(--pf-hue), 3%, 70%);
+ --pf-fg-dim: hsl(var(--pf-hue), 3%, 55%);
+ --pf-accent: #3a8cff;
+
+ --pf-row-h: 22px;
+ --pf-tool-w: 28px;
+ --pf-menu-h: 22px;
+ --pf-options-h: 32px;
+ --pf-tabs-h: 26px;
+ --pf-status-h: 22px;
+ --pf-right-w: 288px;
+ --pf-icon: 16px;
+ --pf-font-ui: 12px;
+ --pf-font-sm: 11px;
+ --pf-font-xs: 10px;
+
+ background: var(--pf-bg-1);
+ color: var(--pf-fg);
+ font-size: var(--pf-font-ui);
+ user-select: none;
+}
+
+.pf-root.pf-density-compact {
+ --pf-row-h: 19px;
+ --pf-menu-h: 20px;
+ --pf-options-h: 28px;
+ --pf-tabs-h: 22px;
+ --pf-status-h: 20px;
+ --pf-tool-w: 24px;
+ --pf-right-w: 260px;
+ --pf-font-ui: 11.5px;
+ --pf-font-sm: 10.5px;
+ --pf-icon: 14px;
+}
+
+.pf-shell {
+ display: grid;
+ grid-template-rows: var(--pf-menu-h) var(--pf-options-h) 1fr var(--pf-status-h);
+ grid-template-columns: var(--pf-tool-w) 1fr var(--pf-right-w);
+ grid-template-areas:
+ 'menu menu menu'
+ 'opts opts opts'
+ 'tools canvas right'
+ 'status status status';
+ height: 100%;
+ width: 100%;
+ background: var(--pf-bg-1);
+}
+
+.pf-menubar {
+ grid-area: menu;
+ display: flex;
+ align-items: center;
+ gap: 0;
+ padding: 0 6px;
+ background: var(--pf-bg-2);
+ border-bottom: 1px solid var(--pf-line);
+ font-size: var(--pf-font-sm);
+}
+.pf-menubar-name {
+ font-size: var(--pf-font-sm);
+ color: var(--pf-fg-mid);
+ margin-right: 14px;
+ padding-left: 4px;
+ font-weight: 500;
+}
+.pf-menubar-name b {
+ color: var(--pf-fg);
+ font-weight: 600;
+}
+.pf-menu-item {
+ padding: 0 8px;
+ height: calc(var(--pf-menu-h) - 2px);
+ display: inline-flex;
+ align-items: center;
+ border-radius: 3px;
+ cursor: default;
+ color: var(--pf-fg);
+}
+.pf-menu-item:hover,
+.pf-menu-item.pf-open {
+ background: var(--pf-bg-4);
+}
+
+.pf-menu-dd {
+ position: absolute;
+ top: var(--pf-menu-h);
+ left: 0;
+ min-width: 240px;
+ background: var(--pf-bg-3);
+ border: 1px solid var(--pf-line);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
+ padding: 4px 0;
+ z-index: 50;
+ font-size: var(--pf-font-sm);
+}
+.pf-menu-dd .pf-mi {
+ display: grid;
+ grid-template-columns: 18px 1fr auto;
+ align-items: center;
+ padding: 0 10px;
+ height: 22px;
+ cursor: default;
+ color: var(--pf-fg);
+ gap: 8px;
+}
+.pf-menu-dd .pf-mi:hover:not(.pf-disabled):not(.pf-sep) {
+ background: var(--pf-accent);
+ color: #fff;
+}
+.pf-menu-dd .pf-mi.pf-disabled {
+ color: var(--pf-fg-dim);
+ pointer-events: none;
+}
+.pf-menu-dd .pf-mi.pf-sep {
+ height: 1px;
+ padding: 0;
+ margin: 4px 0;
+ background: var(--pf-line);
+}
+.pf-menu-dd .pf-kbd {
+ color: var(--pf-fg-dim);
+ font-size: 10.5px;
+ font-family: ui-monospace, Menlo, monospace;
+}
+.pf-menu-dd .pf-mi:hover:not(.pf-disabled):not(.pf-sep) .pf-kbd {
+ color: rgba(255, 255, 255, 0.85);
+}
+
+.pf-options {
+ grid-area: opts;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 10px;
+ background: var(--pf-bg-2);
+ border-bottom: 1px solid var(--pf-line);
+ font-size: var(--pf-font-sm);
+ overflow-x: auto;
+ white-space: nowrap;
+}
+.pf-opt-group {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 6px;
+ height: 22px;
+ border-right: 1px solid var(--pf-line-soft);
+}
+.pf-opt-group:last-child {
+ border-right: 0;
+}
+.pf-opt-label {
+ color: var(--pf-fg-mid);
+ margin-right: 4px;
+}
+.pf-opt-input {
+ height: 20px;
+ background: var(--pf-bg-1);
+ border: 1px solid var(--pf-line);
+ color: var(--pf-fg);
+ font: inherit;
+ padding: 0 6px;
+ border-radius: 2px;
+ width: 54px;
+ font-variant-numeric: tabular-nums;
+}
+.pf-opt-select {
+ height: 20px;
+ background: var(--pf-bg-1);
+ border: 1px solid var(--pf-line);
+ color: var(--pf-fg);
+ font: inherit;
+ padding: 0 16px 0 6px;
+ border-radius: 2px;
+ appearance: none;
+ background-image: url("data:image/svg+xml;utf8, ");
+ background-repeat: no-repeat;
+ background-position: right 4px center;
+}
+.pf-opt-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 3px;
+ background: var(--pf-bg-1);
+ border: 1px solid var(--pf-line);
+ color: var(--pf-fg);
+ cursor: default;
+}
+.pf-opt-btn:hover {
+ background: var(--pf-bg-3);
+}
+.pf-opt-btn.pf-active {
+ background: var(--pf-bg-4);
+ border-color: var(--pf-line-soft);
+}
+
+.pf-tools {
+ grid-area: tools;
+ background: var(--pf-bg-2);
+ border-right: 1px solid var(--pf-line);
+ display: flex;
+ flex-direction: column;
+ padding: 4px 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ scrollbar-width: none;
+}
+.pf-tools::-webkit-scrollbar {
+ display: none;
+}
+.pf-tool-btn {
+ position: relative;
+ width: var(--pf-tool-w);
+ height: var(--pf-tool-w);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--pf-fg-mid);
+ cursor: default;
+ flex-shrink: 0;
+}
+.pf-tool-btn:hover {
+ background: var(--pf-bg-3);
+ color: var(--pf-fg);
+}
+.pf-tool-btn.pf-active {
+ background: var(--pf-bg-4);
+ color: var(--pf-fg);
+}
+.pf-tool-btn.pf-active::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 2px;
+ bottom: 2px;
+ width: 2px;
+ background: var(--pf-accent);
+}
+.pf-tool-btn.pf-stub {
+ opacity: 0.55;
+}
+.pf-tool-btn.pf-stub:hover {
+ color: var(--pf-fg-mid);
+}
+.pf-tool-sep {
+ height: 1px;
+ background: var(--pf-line);
+ margin: 4px 4px;
+ flex-shrink: 0;
+}
+.pf-tool-colors {
+ position: relative;
+ width: var(--pf-tool-w);
+ height: 28px;
+ margin: 4px auto;
+ flex-shrink: 0;
+}
+.pf-tool-colors .pf-fg,
+.pf-tool-colors .pf-bg {
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ border: 1px solid var(--pf-fg-dim);
+ cursor: pointer;
+ overflow: hidden;
+}
+.pf-tool-colors .pf-bg {
+ right: 2px;
+ bottom: 2px;
+}
+.pf-tool-colors .pf-fg {
+ left: 2px;
+ top: 2px;
+ z-index: 1;
+}
+.pf-tool-colors input[type='color'] {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ cursor: pointer;
+ border: 0;
+ padding: 0;
+}
+.pf-tool-mode {
+ width: var(--pf-tool-w);
+ height: var(--pf-tool-w);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--pf-fg-mid);
+ cursor: default;
+ flex-shrink: 0;
+}
+.pf-tool-mode:hover {
+ color: var(--pf-fg);
+}
+
+.pf-canvas-wrap {
+ grid-area: canvas;
+ display: grid;
+ grid-template-rows: var(--pf-tabs-h) 1fr;
+ background: var(--pf-bg-canvas);
+ min-width: 0;
+}
+.pf-tabs {
+ display: flex;
+ align-items: stretch;
+ background: var(--pf-bg-1);
+ border-bottom: 1px solid var(--pf-line);
+ overflow: hidden;
+}
+.pf-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ height: 100%;
+ padding: 0 10px;
+ font-size: var(--pf-font-sm);
+ color: var(--pf-fg-mid);
+ border-right: 1px solid var(--pf-line);
+ background: var(--pf-bg-1);
+ cursor: default;
+ max-width: 320px;
+}
+.pf-tab.pf-active {
+ background: var(--pf-bg-canvas);
+ color: var(--pf-fg);
+}
+.pf-tab-title {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pf-canvas-area {
+ position: relative;
+ overflow: hidden;
+ background: var(--pf-bg-canvas);
+}
+
+.pf-right {
+ grid-area: right;
+ background: var(--pf-bg-2);
+ border-left: 1px solid var(--pf-line);
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+.pf-panel-group {
+ display: flex;
+ flex-direction: column;
+ border-bottom: 2px solid var(--pf-bg-0);
+ flex-shrink: 0;
+}
+.pf-panel-group:last-child {
+ border-bottom: 0;
+}
+.pf-panel-tabs {
+ display: flex;
+ align-items: stretch;
+ background: var(--pf-bg-1);
+ border-bottom: 1px solid var(--pf-line);
+ min-height: 24px;
+}
+.pf-panel-tab {
+ padding: 0 10px;
+ height: 24px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-size: var(--pf-font-sm);
+ color: var(--pf-fg-mid);
+ border-right: 1px solid var(--pf-line);
+ cursor: default;
+}
+.pf-panel-tab.pf-active {
+ color: var(--pf-fg);
+ background: var(--pf-bg-2);
+}
+.pf-panel-tab:hover:not(.pf-active) {
+ color: var(--pf-fg);
+}
+.pf-panel-body {
+ padding: 6px 8px;
+ font-size: var(--pf-font-sm);
+}
+
+.pf-statusbar {
+ grid-area: status;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--pf-bg-2);
+ border-top: 1px solid var(--pf-line);
+ font-size: var(--pf-font-sm);
+ color: var(--pf-fg-mid);
+ padding: 0 8px;
+}
+.pf-zoom-input {
+ width: 54px;
+ height: 18px;
+ border: 1px solid var(--pf-line);
+ background: var(--pf-bg-1);
+ color: var(--pf-fg);
+ font: inherit;
+ padding: 0 4px;
+ border-radius: 2px;
+}
+
+/* Outside-click backdrop for menu dropdowns. */
+.pf-menu-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+}
diff --git a/src/components/image-editor/tool-meta.ts b/src/components/image-editor/tool-meta.ts
new file mode 100644
index 0000000..8d865c0
--- /dev/null
+++ b/src/components/image-editor/tool-meta.ts
@@ -0,0 +1,28 @@
+import type { Tool } from '@/lib/image-editor/types'
+
+/**
+ * Tools rendered in the palette but not implemented end-to-end. Clicking a
+ * stub tool surfaces a toast and leaves the active tool unchanged.
+ *
+ * Kept in sync by hand with `ToolsPalette.tsx` — small enough that the
+ * duplication isn't worth a build-time dance.
+ */
+export const STUB_TOOLS: ReadonlySet = new Set([
+ 'marquee',
+ 'lasso',
+ 'polyLasso',
+ 'wand',
+ 'spotHeal',
+ 'stamp',
+ 'historyBrush',
+ 'gradient',
+ 'bucket',
+ 'blur',
+ 'dodge',
+ 'pen',
+ 'arrowPath',
+ 'hand',
+ 'rotateView',
+ 'frame',
+ 'note',
+])
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 84ccb33..c1ad936 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -465,8 +465,67 @@
"zoom": "Zoom",
"crop": "Crop",
"ellipse": "Ellipse",
- "line": "Line"
+ "line": "Line",
+ "marquee": "Rectangular Marquee",
+ "lasso": "Lasso",
+ "polyLasso": "Polygonal Lasso",
+ "wand": "Magic Wand",
+ "spotHeal": "Spot Healing Brush",
+ "stamp": "Clone Stamp",
+ "historyBrush": "History Brush",
+ "gradient": "Gradient",
+ "bucket": "Paint Bucket",
+ "blur": "Blur",
+ "dodge": "Dodge",
+ "pen": "Pen",
+ "arrowPath": "Path Selection",
+ "hand": "Hand",
+ "rotateView": "Rotate View",
+ "frame": "Frame",
+ "note": "Note"
+ },
+ "menu": {
+ "file": "File",
+ "edit": "Edit",
+ "image": "Image",
+ "layer": "Layer",
+ "view": "View",
+ "open": "Open / Replace…",
+ "saveProject": "Save Project (.json)",
+ "download": "Export…",
+ "exportPng": "Export as PNG",
+ "exportJpeg": "Export as JPEG",
+ "exportWebp": "Export as WebP",
+ "undo": "Undo",
+ "redo": "Redo",
+ "rotate90": "Rotate 90° CW",
+ "flipH": "Flip Horizontal",
+ "flipV": "Flip Vertical",
+ "duplicateLayer": "Duplicate Layer",
+ "deleteLayer": "Delete Layer",
+ "zoomIn": "Zoom In",
+ "zoomOut": "Zoom Out",
+ "zoomFit": "Fit on Screen",
+ "toggleFocus": "Toggle Focus Mode"
},
+ "panelLayers": "Layers",
+ "panelChannels": "Channels",
+ "panelPaths": "Paths",
+ "panelProperties": "Properties",
+ "panelInfo": "Info",
+ "panelAdjust": "Adjust",
+ "panelNavigator": "Navigator",
+ "panelStubChannels": "Channels panel — coming soon (per-channel R/G/B/A view).",
+ "panelStubPaths": "Paths panel — coming soon (vector paths from the Pen tool).",
+ "panelStubInfo": "Info panel — coming soon (live cursor color/coords).",
+ "moveToolHint": "Move (V): drag layers; Cmd/Ctrl+J duplicate; Delete remove.",
+ "zoomToolHint": "Zoom (Z): click to zoom in 2×, Alt-click to zoom out. Cmd/Ctrl+wheel zooms at cursor.",
+ "eyedropperHint": "Eyedropper (I): click anywhere on the canvas to set the foreground color.",
+ "textToolHint": "Type (T): click on the canvas to add text.",
+ "toolStubHint": "{{tool}} is a placeholder — the palette button is here for parity with Photoshop, but the tool isn't implemented yet.",
+ "toolStubToast": "{{tool}} is not yet implemented.",
+ "statusTip": "Tool: {{tool}} · Hold Space to pan, Z for zoom tool",
+ "zoom": "Zoom",
"cropApply": "Apply crop",
"cropCancel": "Cancel",
"cropClear": "Clear crop",
diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json
index 4882586..30ff37d 100644
--- a/src/i18n/zh-CN.json
+++ b/src/i18n/zh-CN.json
@@ -465,8 +465,67 @@
"zoom": "缩放",
"crop": "裁剪",
"ellipse": "椭圆",
- "line": "直线"
+ "line": "直线",
+ "marquee": "矩形选框",
+ "lasso": "套索",
+ "polyLasso": "多边形套索",
+ "wand": "魔棒",
+ "spotHeal": "污点修复画笔",
+ "stamp": "仿制图章",
+ "historyBrush": "历史记录画笔",
+ "gradient": "渐变",
+ "bucket": "油漆桶",
+ "blur": "模糊",
+ "dodge": "减淡",
+ "pen": "钢笔",
+ "arrowPath": "路径选择",
+ "hand": "抓手",
+ "rotateView": "旋转视图",
+ "frame": "画框",
+ "note": "注释"
+ },
+ "menu": {
+ "file": "文件",
+ "edit": "编辑",
+ "image": "图像",
+ "layer": "图层",
+ "view": "视图",
+ "open": "打开 / 替换…",
+ "saveProject": "保存项目 (.json)",
+ "download": "导出…",
+ "exportPng": "导出为 PNG",
+ "exportJpeg": "导出为 JPEG",
+ "exportWebp": "导出为 WebP",
+ "undo": "撤销",
+ "redo": "重做",
+ "rotate90": "旋转 90°",
+ "flipH": "水平翻转",
+ "flipV": "垂直翻转",
+ "duplicateLayer": "复制图层",
+ "deleteLayer": "删除图层",
+ "zoomIn": "放大",
+ "zoomOut": "缩小",
+ "zoomFit": "适合屏幕",
+ "toggleFocus": "切换焦点模式"
},
+ "panelLayers": "图层",
+ "panelChannels": "通道",
+ "panelPaths": "路径",
+ "panelProperties": "属性",
+ "panelInfo": "信息",
+ "panelAdjust": "调整",
+ "panelNavigator": "导航器",
+ "panelStubChannels": "通道面板 — 即将推出(按 R/G/B/A 通道分别查看)。",
+ "panelStubPaths": "路径面板 — 即将推出(钢笔工具的矢量路径)。",
+ "panelStubInfo": "信息面板 — 即将推出(实时光标颜色 / 坐标)。",
+ "moveToolHint": "移动 (V):拖动图层;Cmd/Ctrl+J 复制;Delete 删除。",
+ "zoomToolHint": "缩放 (Z):单击放大 2×,Alt 单击缩小。Cmd/Ctrl+滚轮在光标位置缩放。",
+ "eyedropperHint": "吸管 (I):在画布上单击以拾取前景色。",
+ "textToolHint": "文字 (T):在画布上单击以新增文字。",
+ "toolStubHint": "{{tool}} 仅作占位 —— 工具栏按钮是为了对齐 Photoshop 而保留,但功能尚未实现。",
+ "toolStubToast": "{{tool}} 暂未实现。",
+ "statusTip": "工具:{{tool}} · 按住空格平移,Z 切换缩放工具",
+ "zoom": "缩放",
"cropApply": "应用裁剪",
"cropCancel": "取消",
"cropClear": "清除裁剪",
diff --git a/src/lib/image-editor/types.ts b/src/lib/image-editor/types.ts
index 6e6f1c0..87df956 100644
--- a/src/lib/image-editor/types.ts
+++ b/src/lib/image-editor/types.ts
@@ -22,7 +22,20 @@ export const BLEND_MODES: BlendMode[] = [
'lighten',
]
+/**
+ * Editor tool. Tools split into two camps:
+ *
+ * **Functional**: implemented and produce a real result on the canvas —
+ * none, rect, ellipse, line, arrow, text, mosaic, brush, eraser, mask,
+ * eyedropper, crop, zoom.
+ *
+ * **Stub** (palette button only, marked as not-yet-implemented in UI; chosen
+ * to round out the PS-aligned tool list): marquee, lasso, polyLasso, wand,
+ * spotHeal, stamp, historyBrush, gradient, bucket, blur, dodge, pen,
+ * arrowPath, hand, rotateView, frame, note.
+ */
export type Tool =
+ // Functional
| 'none'
| 'rect'
| 'arrow'
@@ -36,6 +49,24 @@ export type Tool =
| 'crop'
| 'ellipse'
| 'line'
+ // Stub (PS palette completeness)
+ | 'marquee'
+ | 'lasso'
+ | 'polyLasso'
+ | 'wand'
+ | 'spotHeal'
+ | 'stamp'
+ | 'historyBrush'
+ | 'gradient'
+ | 'bucket'
+ | 'blur'
+ | 'dodge'
+ | 'pen'
+ | 'arrowPath'
+ | 'hand'
+ | 'rotateView'
+ | 'frame'
+ | 'note'
export type Transforms = {
rotation: Rotation
diff --git a/src/pages/ImageEditor.tsx b/src/pages/ImageEditor.tsx
index b090b51..86e93fc 100644
--- a/src/pages/ImageEditor.tsx
+++ b/src/pages/ImageEditor.tsx
@@ -3,11 +3,14 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Canvas, type CanvasHandle } from '@/components/image-editor/Canvas'
import { DropZone } from '@/components/image-editor/DropZone'
+import { MenuBar } from '@/components/image-editor/MenuBar'
+import { OptionsBar } from '@/components/image-editor/OptionsBar'
import { RightSidebar } from '@/components/image-editor/RightSidebar'
import { StatusBar } from '@/components/image-editor/StatusBar'
import { ToolsPalette } from '@/components/image-editor/ToolsPalette'
-import { TopActionBar } from '@/components/image-editor/TopActionBar'
+import { STUB_TOOLS } from '@/components/image-editor/tool-meta'
import { Workspace, type WorkspaceHandle } from '@/components/image-editor/Workspace'
+import '@/components/image-editor/pixelforge.css'
import { initialState, PREVIEW_MAX } from '@/lib/image-editor/defaults'
import { useHistoryState } from '@/lib/image-editor/history'
import { fileToDataUrl, useImageCache } from '@/lib/image-editor/image-cache'
@@ -24,8 +27,31 @@ import type {
Layer,
OutputFormat,
Tool,
+ Transforms,
} from '@/lib/image-editor/types'
+/**
+ * Image editor page (PixelForge shell).
+ *
+ * Layout — a 4-row × 3-col CSS grid (`pf-shell`):
+ *
+ * ┌──────────────────────────────────┐
+ * │ menu bar │
+ * ├──────────────────────────────────┤
+ * │ options bar (context-sensitive) │
+ * ├──────┬───────────────────┬───────┤
+ * │tools │ tab strip + canvas│ panels│
+ * │ rail │ │ right │
+ * ├──────┴───────────────────┴───────┤
+ * │ status bar │
+ * └──────────────────────────────────┘
+ *
+ * Drawing/state lives in the existing Canvas/Workspace; the new shell
+ * components (MenuBar, OptionsBar, ToolsPalette, RightSidebar, StatusBar)
+ * are pure presentational layers that wire into the same state. Tools that
+ * aren't yet implemented are rendered in the palette as "stub" buttons —
+ * they don't change tool state; clicking surfaces a toast.
+ */
export function ImageEditorPage() {
const { t } = useTranslation()
@@ -51,19 +77,14 @@ export function ImageEditorPage() {
const [selectedLayerId, setSelectedLayerId] = useState('image')
const [outFormat, setOutFormat] = useState('png')
- const [outQuality, setOutQuality] = useState(92)
+ const outQuality = 92
- // Focus mode: editor takes over the viewport, hides toolbox chrome.
const [focused, setFocused] = useState(false)
const canvasRef = useRef(null)
const workspaceRef = useRef(null)
- // Cache of HTMLImageElements for image-shape layers (drag-drop'd images).
const { cache: imageCache, ensure: ensureImage } = useImageCache()
- // Refs hold the latest layer-op callbacks so the keyboard useEffect doesn't
- // need them in deps (they're declared further down — TDZ otherwise). Each
- // render updates the ref; the keyboard handler always reads the freshest fn.
const duplicateRef = useRef<() => void>(() => {})
const moveLayerRef = useRef<(d: 'forward' | 'backward' | 'front' | 'back') => void>(() => {})
const deleteLayerRef = useRef<() => void>(() => {})
@@ -84,15 +105,6 @@ export function ImageEditorPage() {
setPan({ x: 0, y: 0 })
}, [])
- /**
- * Zoom by `factor` so the point under the cursor stays fixed on screen.
- * Used by both the Z (zoom) tool click and Cmd/Ctrl+wheel.
- *
- * Math: the wrapper is rendered with `translate(pan) scale(zoom)` around its
- * geometric centre. To keep the cursor anchored when zoom changes from z₀ to
- * z₁, the new pan must satisfy:
- * panNew = pan + (cursor - wrapperCentre) * (1 - z₁/z₀)
- */
const zoomAtPoint = useCallback(
(clientX: number, clientY: number, factor: number) => {
setZoom((z0) => {
@@ -114,70 +126,49 @@ export function ImageEditorPage() {
[],
)
- // ── Global keyboard shortcuts ────────────────────────────────────────────
- // F = focus toggle / Esc exit (existing).
- // Space-hold = pan tool override.
- // Z / Shift+Z / Cmd+/-/0/1 = zoom.
- // V / M / T / B / E / A = tool shortcuts (PS conventions).
- // (Cmd+Z / Cmd+Shift+Z handled in useHistoryState.)
+ // Stub-tool toast — also surfaced when a stub-tool keyboard shortcut fires.
+ const stubMsg = useCallback(
+ (toolName: string) =>
+ toast.message(t('pages.imageEditor.toolStubToast', { tool: toolName })),
+ [t],
+ )
+
+ // Try to set tool; if it's in the stub set, show a toast and don't change state.
+ const trySetTool = useCallback(
+ (next: Tool) => {
+ if (STUB_TOOLS.has(next)) {
+ stubMsg(t(`pages.imageEditor.tool.${next}`))
+ return
+ }
+ setTool(next)
+ },
+ [stubMsg, t],
+ )
+
+ // ── Global keyboard shortcuts (PS-style) ────────────────────────────────
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement | null)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
- // Space → pan mode (no modifiers).
if (e.code === 'Space' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
e.preventDefault()
setPanMode(true)
return
}
- // Cmd/Ctrl combos.
const mod = e.metaKey || e.ctrlKey
if (mod) {
- if (e.key === '+' || e.key === '=') {
- e.preventDefault()
- zoomIn()
- return
- }
- if (e.key === '-' || e.key === '_') {
- e.preventDefault()
- zoomOut()
- return
- }
- if (e.key === '0') {
- e.preventDefault()
- zoomReset()
- return
- }
- if (e.key === '1') {
- e.preventDefault()
- setZoom(1)
- setPan({ x: 0, y: 0 })
- return
- }
- // Cmd+J = duplicate selected layer (PS convention).
- if (e.key === 'j' || e.key === 'J') {
- e.preventDefault()
- duplicateRef.current()
- return
- }
- // Cmd+] / [ = bring forward / send backward. Add Shift for to-front /
- // to-back.
- if (e.key === ']') {
- e.preventDefault()
- moveLayerRef.current(e.shiftKey ? 'front' : 'forward')
- return
- }
- if (e.key === '[') {
- e.preventDefault()
- moveLayerRef.current(e.shiftKey ? 'back' : 'backward')
- return
- }
+ if (e.key === '+' || e.key === '=') { e.preventDefault(); zoomIn(); return }
+ if (e.key === '-' || e.key === '_') { e.preventDefault(); zoomOut(); return }
+ if (e.key === '0') { e.preventDefault(); zoomReset(); return }
+ if (e.key === '1') { e.preventDefault(); setZoom(1); setPan({ x: 0, y: 0 }); return }
+ if (e.key === 'j' || e.key === 'J') { e.preventDefault(); duplicateRef.current(); return }
+ if (e.key === ']') { e.preventDefault(); moveLayerRef.current(e.shiftKey ? 'front' : 'forward'); return }
+ if (e.key === '[') { e.preventDefault(); moveLayerRef.current(e.shiftKey ? 'back' : 'backward'); return }
return
}
- // Delete / Backspace = remove selected layer (image is protected).
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedLayerId && selectedLayerId !== 'image') {
e.preventDefault()
@@ -186,14 +177,7 @@ export function ImageEditorPage() {
}
}
- // No-modifier letter shortcuts.
- if (e.key === 'f' || e.key === 'F') {
- e.preventDefault()
- setFocused((v) => !v)
- return
- }
- // Crop commit/cancel — Enter applies the pending drag, Escape cancels.
- // Both no-op if no crop is pending. Escape also exits focus mode (below).
+ if (e.key === 'f' || e.key === 'F') { e.preventDefault(); setFocused((v) => !v); return }
if (e.key === 'Enter' && canvasRef.current?.hasPendingCrop()) {
e.preventDefault()
canvasRef.current.commitPendingCrop()
@@ -204,40 +188,40 @@ export function ImageEditorPage() {
canvasRef.current.cancelPendingCrop()
return
}
- if (e.key === 'Escape' && focused) {
- e.preventDefault()
- setFocused(false)
- return
- }
- // X = swap fg/bg. D = default colors (black/white). PS conventions.
- if (e.key === 'x' || e.key === 'X') {
- e.preventDefault()
- swapColors()
- return
+ if (e.key === 'Escape' && focused) { e.preventDefault(); setFocused(false); return }
+ if (e.key === 'x' || e.key === 'X') { e.preventDefault(); swapColors(); return }
+ if (e.key === 'd' || e.key === 'D') { e.preventDefault(); resetColors(); return }
+
+ // Tool shortcuts. PS-aligned: stub-tool shortcuts (M/L/W/J/S/Y/G/O/P/H)
+ // surface a toast via trySetTool rather than silently doing nothing.
+ const map: Record = {
+ v: 'none',
+ m: 'marquee',
+ l: 'lasso',
+ w: 'wand',
+ c: 'crop',
+ i: 'eyedropper',
+ j: 'spotHeal',
+ b: 'brush',
+ s: 'stamp',
+ y: 'historyBrush',
+ e: 'eraser',
+ g: 'gradient',
+ o: 'dodge',
+ p: 'pen',
+ t: 'text',
+ u: 'rect',
+ h: 'hand',
+ z: 'zoom',
}
- if (e.key === 'd' || e.key === 'D') {
+ const next = map[e.key.toLowerCase()]
+ if (next) {
e.preventDefault()
- resetColors()
- return
+ trySetTool(next)
}
- // Tool shortcuts (PS-style). Z is the zoom *tool* (click to zoom in,
- // Alt+click to zoom out) — not a one-shot zoom action.
- if (e.key === 'v') { e.preventDefault(); setTool('none'); return }
- if (e.key === 'm') { e.preventDefault(); setTool('rect'); return }
- if (e.key === 'a') { e.preventDefault(); setTool('arrow'); return }
- if (e.key === 't') { e.preventDefault(); setTool('text'); return }
- if (e.key === 'b') { e.preventDefault(); setTool('brush'); return }
- if (e.key === 'e') { e.preventDefault(); setTool('eraser'); return }
- if (e.key === 'i') { e.preventDefault(); setTool('eyedropper'); return }
- if (e.key === 'z') { e.preventDefault(); setTool('zoom'); return }
- if (e.key === 'c') { e.preventDefault(); setTool('crop'); return }
- if (e.key === 'o') { e.preventDefault(); setTool('ellipse'); return }
- if (e.key === 'l') { e.preventDefault(); setTool('line'); return }
}
const onKeyUp = (e: KeyboardEvent) => {
- if (e.code === 'Space') {
- setPanMode(false)
- }
+ if (e.code === 'Space') setPanMode(false)
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
@@ -245,9 +229,9 @@ export function ImageEditorPage() {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
- }, [focused, zoomIn, zoomOut, zoomReset, swapColors, resetColors, selectedLayerId])
+ }, [focused, zoomIn, zoomOut, zoomReset, swapColors, resetColors, selectedLayerId, trySetTool])
- // ── State helpers ────────────────────────────────────────────────────────
+ // ── Layer state helpers ──────────────────────────────────────────────────
const setLayers = useCallback(
(layers: Layer[]) => history.set({ ...state, layers }),
[history, state],
@@ -278,7 +262,6 @@ export function ImageEditorPage() {
history.set({ ...state, imageLayer: { ...state.imageLayer, ...patch } }),
[history, state],
)
- // Replace a layer's full data — used by Canvas after a move/resize commits.
const commitLayerUpdate = useCallback(
(id: string, layer: Layer) =>
history.set({
@@ -288,12 +271,6 @@ export function ImageEditorPage() {
[history, state],
)
- // ── Layer keyboard ops (PS-style) ────────────────────────────────────────
- // Cmd/Ctrl+J = duplicate selected layer (10px offset).
- // Cmd+] / [ = bring forward / send backward (add Shift = to-front / to-back).
- // Delete / Backspace = remove. All no-op when 'image' is selected.
- // Stored on refs so the keyboard useEffect can reach them without re-binding
- // every time state changes; useEffect updates the refs each render.
useEffect(() => {
duplicateRef.current = () => {
const orig = state.layers.find((l) => l.id === selectedLayerId)
@@ -332,8 +309,7 @@ export function ImageEditorPage() {
}
})
- // Crop commit → apply (or replace) state.cropRect. Rect is already in the
- // post-rotation preview-pixel space relative to the original image.
+ // ── Crop ─────────────────────────────────────────────────────────────────
const handleCommitCrop = useCallback(
(rect: { x: number; y: number; w: number; h: number }) => {
history.set({ ...state, cropRect: rect })
@@ -386,11 +362,8 @@ export function ImageEditorPage() {
[history, t],
)
- // Hidden replace-image input triggered from the top action bar.
const replaceInputRef = useRef(null)
- // Drop an image file onto the workspace → add as a new image-shape layer
- // centred on the canvas, sized to fit half the shorter canvas dimension.
const handleDropImage = useCallback(
async (file: File) => {
if (!file.type.startsWith('image/')) {
@@ -401,10 +374,6 @@ export function ImageEditorPage() {
const dataUrl = await fileToDataUrl(file)
const img = await ensureImage(dataUrl)
if (!image) return
- // Shape coords live in *preview-canvas pixel space* — same as
- // eventToCanvasXY. previewScale converts source-image px → preview px,
- // and is < 1 whenever the source exceeds PREVIEW_MAX. The dropped
- // image is sized to ~half the canvas's shorter dim and centred.
const { baseW, baseH } = dimsAfterRotation(image, state)
const previewScale = Math.min(1, PREVIEW_MAX / Math.max(baseW, baseH, 1))
const previewW = baseW * previewScale
@@ -437,41 +406,51 @@ export function ImageEditorPage() {
[],
)
- // ── Download ─────────────────────────────────────────────────────────────
- const handleDownload = async () => {
- if (!image || !canvasRef.current) return
- const exportCanvas = document.createElement('canvas')
- canvasRef.current.exportTo(exportCanvas)
- const mime =
- outFormat === 'png' ? 'image/png' : outFormat === 'jpeg' ? 'image/jpeg' : 'image/webp'
- const ext = outFormat === 'jpeg' ? 'jpg' : outFormat
- const quality = outFormat === 'png' ? undefined : outQuality / 100
- const blob: Blob | null = await new Promise((resolve) =>
- exportCanvas.toBlob((b) => resolve(b), mime, quality),
- )
- if (!blob) {
- toast.error(t('pages.imageEditor.errExport'))
- return
- }
- triggerDownload(blob, `${filename}_edited.${ext}`)
- toast.success(t('pages.imageEditor.downloaded', { format: outFormat.toUpperCase() }))
- }
+ // ── Download / save ──────────────────────────────────────────────────────
+ /**
+ * Render and download the current canvas in the requested format. If
+ * `format` is omitted, falls back to the most recently chosen format
+ * (defaults to PNG).
+ */
+ const exportImage = useCallback(
+ async (format?: OutputFormat) => {
+ if (!image || !canvasRef.current) return
+ const fmt = format ?? outFormat
+ if (format) setOutFormat(format)
+ const exportCanvas = document.createElement('canvas')
+ canvasRef.current.exportTo(exportCanvas)
+ const mime =
+ fmt === 'png' ? 'image/png' : fmt === 'jpeg' ? 'image/jpeg' : 'image/webp'
+ const ext = fmt === 'jpeg' ? 'jpg' : fmt
+ const quality = fmt === 'png' ? undefined : outQuality / 100
+ const blob: Blob | null = await new Promise((resolve) =>
+ exportCanvas.toBlob((b) => resolve(b), mime, quality),
+ )
+ if (!blob) {
+ toast.error(t('pages.imageEditor.errExport'))
+ return
+ }
+ triggerDownload(blob, `${filename}_edited.${ext}`)
+ toast.success(t('pages.imageEditor.downloaded', { format: fmt.toUpperCase() }))
+ },
+ [image, outFormat, outQuality, filename, t],
+ )
+ const handleDownload = useCallback(() => exportImage(), [exportImage])
- const handleSaveProject = () => {
+ const handleSaveProject = useCallback(() => {
if (!image) return
const blob = serializeProject({ image, filename, state })
triggerDownload(blob, `${filename}.toolbox-image.json`)
toast.success(t('pages.imageEditor.projectSaved'))
- }
+ }, [image, filename, state, t])
- // ── Render ───────────────────────────────────────────────────────────────
- // Focus mode: position:fixed over the entire viewport, hides toolbox chrome.
- // Embedded mode: lives inside , fills available height (the toolbox
- // topbar is 3.5rem tall, so we subtract that to get a clean viewport fit).
- const rootClass = focused
- ? 'fixed inset-0 z-50 flex h-svh flex-col bg-background'
- : 'flex h-[calc(100svh-3.5rem)] flex-col'
+ const setTransforms = useCallback(
+ (transforms: Transforms) => history.set({ ...state, transforms }),
+ [history, state],
+ )
+ // ── Render ───────────────────────────────────────────────────────────────
+ // Empty state — drop zone, no shell.
if (!image) {
return (
@@ -488,25 +467,14 @@ export function ImageEditorPage() {
)
}
+ const rootClass = focused
+ ? 'pf-root fixed inset-0 z-50 h-svh w-svw'
+ : 'pf-root h-[calc(100svh-3.5rem)] w-full'
+
+ // OptionsBar reflects whichever the user thinks the active tool is —
+ // if pan mode is on (Space held), keep showing the underlying tool.
return (
-
replaceInputRef.current?.click()}
- focused={focused}
- toggleFocus={() => setFocused((v) => !v)}
- />
-
- {/* Contextual crop banner — shown when the crop tool is active or a
- crop is already applied. Keyboard: Enter = apply pending, Esc =
- cancel pending. Button: clear an applied crop. */}
- {(tool === 'crop' || state.cropRect) && (
-
- {t('pages.imageEditor.cropPendingHint')}
- {state.cropRect && (
-
- {t('pages.imageEditor.cropClear')}
-
- )}
-
- )}
+
+
replaceInputRef.current?.click(),
+ save: handleSaveProject,
+ download: handleDownload,
+ exportPng: () => exportImage('png'),
+ exportJpeg: () => exportImage('jpeg'),
+ exportWebp: () => exportImage('webp'),
+ undo: history.undo,
+ redo: history.redo,
+ canUndo: history.canUndo,
+ canRedo: history.canRedo,
+ rotate90: () =>
+ setTransforms({
+ ...state.transforms,
+ rotation: ((state.transforms.rotation + 90) % 360) as 0 | 90 | 180 | 270,
+ }),
+ flipH: () => setTransforms({ ...state.transforms, flipH: !state.transforms.flipH }),
+ flipV: () => setTransforms({ ...state.transforms, flipV: !state.transforms.flipV }),
+ duplicateLayer: () => duplicateRef.current(),
+ deleteLayer: () => deleteLayerRef.current(),
+ zoomIn,
+ zoomOut,
+ zoomFit: zoomReset,
+ toggleFocus: () => setFocused((v) => !v),
+ }}
+ />
+
+ setColors((s) => ({ ...s, fg: c }))}
+ strokeWidth={strokeWidth}
+ setStrokeWidth={setStrokeWidth}
+ isStubTool={STUB_TOOLS.has(tool)}
+ hasActiveCrop={!!state.cropRect}
+ onClearCrop={handleClearCrop}
+ />
-
setColors((s) => ({ ...s, fg: c }))}
setBgColor={(c) => setColors((s) => ({ ...s, bg: c }))}
swapColors={swapColors}
resetColors={resetColors}
- strokeWidth={strokeWidth}
- setStrokeWidth={setStrokeWidth}
+ onStubClick={stubMsg}
/>
-
-
+
+
+ {filename} · RGB/8
+
+ {state.cropRect && (
+
+ ✕ {t('pages.imageEditor.cropClear')}
+
+ )}
+
+
setFocused((v) => !v)}
+ title={t(focused ? 'pages.imageEditor.focusExitHint' : 'pages.imageEditor.focusEnterHint')}
+ style={{ color: 'var(--pf-fg-mid)' }}
+ >
+ ⛶ F
+
+
+
+
-
+ onWheelZoom={zoomAtPoint}
+ onDropFile={handleDropImage}
+ >
+
+
+
history.set({ ...state, transforms })}
+ setTransforms={setTransforms}
setAdjust={(adjust) => history.set({ ...state, adjust })}
+ zoom={zoom}
+ />
+
+
-
)
}