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 ( +
+
+ {t('pages.imageEditor.strokeWidth')}: + setStrokeWidth(Number(e.target.value) || 1)} + /> +
+ {tool === 'brush' && ( +
+ {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', + }} + /> +
+ )} +
+ ) + } + + // Shape tools — stroke width + color. + if (tool === 'rect' || tool === 'ellipse' || tool === 'line' || tool === 'arrow') { + return ( +
+
+ {t('pages.imageEditor.strokeWidth')}: + setStrokeWidth(Number(e.target.value) || 1)} + /> +
+
+ {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', + }} + /> +
+
+ ) + } + + if (tool === 'crop') { + return ( +
+
+ + {t('pages.imageEditor.cropPendingHint')} + +
+ {hasActiveCrop && ( +
+ +
+ )} +
+ ) + } + + 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 ( -