'use client' import { CopyIcon, MinusIcon, SquareIcon, XIcon } from 'lucide-react' import Image from 'next/image' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { fitCanvasToViewport, resetCanvasScale } from '@/components/Canvas' import { SettingsDialog, type TabId } from '@/components/SettingsDialog' import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSeparator, MenubarShortcut, MenubarTrigger, } from '@/components/ui/menubar' import { useScene } from '@/hooks/useScene' import { getConfig, startPipeline } from '@/lib/api/default/default' import { isTauri, openExternalUrl } from '@/lib/backend' import { exportCurrentProjectAs, importPages } from '@/lib/io/pagesIo' import { closeProject, redoOp, selectAllTextNodesOnCurrentPage, undoOp } from '@/lib/io/scene' import { formatShortcutForDisplay, getPlatform } from '@/lib/shortcutUtils' import { useEditorUiStore } from '@/lib/stores/editorUiStore' import { usePreferencesStore } from '@/lib/stores/preferencesStore' import { useSelectionStore } from '@/lib/stores/selectionStore' const windowControls = { async close() { const { getCurrentWindow } = await import('@tauri-apps/api/window') return getCurrentWindow().close() }, async minimize() { const { getCurrentWindow } = await import('@tauri-apps/api/window') return getCurrentWindow().minimize() }, async toggleMaximize() { const { getCurrentWindow } = await import('@tauri-apps/api/window') return getCurrentWindow().toggleMaximize() }, async isMaximized() { const { getCurrentWindow } = await import('@tauri-apps/api/window') return getCurrentWindow().isMaximized() }, } type MenuItem = { label: string onSelect?: () => void | Promise disabled?: boolean testId?: string } type MenuSection = { label: string items: MenuItem[] triggerTestId?: string } export function MenuBar() { const { t } = useTranslation() const [settingsOpen, setSettingsOpen] = useState(false) const [settingsTab, setSettingsTab] = useState('appearance') const hasPage = useSelectionStore((s) => s.pageId !== null) const hasScene = useScene().scene !== null const shortcuts = usePreferencesStore((state) => state.shortcuts) const isMac = useMemo(() => getPlatform() === 'mac', []) const requirePageId = () => { const id = useSelectionStore.getState().pageId if (!id) throw new Error('No current page selected') return id } const runPipeline = async (opts: { pageId?: string }) => { const cfg = await getConfig() if (!cfg.pipeline) return const p = cfg.pipeline const steps = [ p.detector, p.segmenter, p.bubble_segmenter, p.font_detector, p.ocr, p.translator, p.inpainter, p.renderer, ].filter((s): s is string => !!s) const editor = useEditorUiStore.getState() const prefs = usePreferencesStore.getState() await startPipeline({ steps, pages: opts.pageId ? [opts.pageId] : undefined, targetLanguage: editor.selectedLanguage, systemPrompt: prefs.customSystemPrompt, defaultFont: prefs.defaultFont, }) } const runInpaint = async (pageId: string) => { const cfg = await getConfig() if (!cfg.pipeline?.inpainter) return await startPipeline({ steps: [cfg.pipeline.inpainter], pages: [pageId] }) } const exportItems: MenuItem[] = [ { label: t('menu.export'), onSelect: () => void exportCurrentProjectAs('rendered', [requirePageId()]), disabled: !hasPage, testId: 'menu-file-export', }, { label: t('menu.exportPsd'), onSelect: () => void exportCurrentProjectAs('psd', [requirePageId()]), disabled: !hasPage, testId: 'menu-file-export-psd', }, { label: t('menu.exportAllInpainted'), onSelect: () => void exportCurrentProjectAs('inpainted'), disabled: !hasScene, testId: 'menu-file-export-all-inpainted', }, { label: t('menu.exportAllRendered'), onSelect: () => void exportCurrentProjectAs('rendered'), disabled: !hasScene, testId: 'menu-file-export-all-rendered', }, ] const menus: MenuSection[] = [ { label: t('menu.view'), items: [ { label: t('menu.fitWindow'), onSelect: fitCanvasToViewport }, { label: t('menu.originalSize'), onSelect: resetCanvasScale }, ], }, { label: t('menu.process'), triggerTestId: 'menu-process-trigger', items: [ { label: t('menu.processCurrent'), onSelect: () => void runPipeline({ pageId: requirePageId() }), disabled: !hasPage, testId: 'menu-process-current', }, { label: t('menu.redoInpaintRender'), onSelect: () => void runInpaint(requirePageId()), disabled: !hasPage, testId: 'menu-process-rerender', }, { label: t('menu.processAll'), onSelect: () => void runPipeline({}), disabled: !hasScene, testId: 'menu-process-all', }, ], }, ] const helpMenuItems: MenuItem[] = [ { label: t('menu.discord'), onSelect: () => openExternalUrl('https://discord.gg/mHvHkxGnUY') }, { label: t('menu.github'), onSelect: () => openExternalUrl('https://github.com/mayocream/koharu'), }, ] const isNativeMacOS = isTauri() && isMac const isWindowsTauri = isTauri() && !isMac return (
{isNativeMacOS && }
Koharu
{t('menu.file')} void importPages('replace', 'files')} > {t('menu.openFiles')} void importPages('replace', 'folder')} > {t('menu.openFolder')} void exportCurrentProjectAs('khr')} > {t('menu.saveAs')} {exportItems.map((item) => ( void item.onSelect?.() : undefined} > {item.label} ))} void closeProject()} > {t('menu.closeProject')} { setSettingsTab('appearance') setSettingsOpen(true) }} > {t('menu.settings')} {t('menu.edit')} void undoOp()} > {t('menu.undo')} {formatShortcutForDisplay(shortcuts.undo, isMac)} void redoOp()} > {t('menu.redo')} {formatShortcutForDisplay(shortcuts.redo, isMac)} selectAllTextNodesOnCurrentPage()} > {t('menu.selectAll')} {isMac ? '⌘A' : 'Ctrl+A'} {menus.map(({ label, items, triggerTestId }) => ( {label} {items.map((item) => ( void item.onSelect?.() : undefined} > {item.label} ))} ))} {t('menu.help')} {helpMenuItems.map((item) => ( void item.onSelect?.() : undefined} > {item.label} ))} { setSettingsTab('about') setSettingsOpen(true) }} > {t('settings.about')}
{isWindowsTauri && }
) } function MacOSControls() { return (
) } function WindowControls() { const [maximized, setMaximized] = useState(false) const updateMaximized = useCallback(async () => { setMaximized(await windowControls.isMaximized()) }, []) useEffect(() => { void updateMaximized() const onResize = () => void updateMaximized() window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, [updateMaximized]) return (
) }