| 1 | import { useCallback, type ReactElement } from 'react' |
| 2 | import { useNavigate } from 'react-router-dom' |
| 3 | import { |
| 4 | Download, |
| 5 | ExternalLink, |
| 6 | FileSearch, |
| 7 | History, |
| 8 | Home, |
| 9 | Redo2, |
| 10 | RotateCcw, |
| 11 | Save, |
| 12 | Undo2 |
| 13 | } from 'lucide-react' |
| 14 | import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip' |
| 15 | import { useT } from '../../i18n' |
| 16 | import { ipc } from '../../lib/ipc' |
| 17 | import { useHtmlEditorStore } from '../../store/htmlEditorStore' |
| 18 | import { useHtmlEditStore } from '../../store/htmlEditStore' |
| 19 | import { useHtmlEditHistoryStore } from '../../store/htmlEditHistoryStore' |
| 20 | import { useHtmlEditorAiStore } from '../../store/htmlEditorAiStore' |
| 21 | import { useHtmlEditorUiStore } from '../../store/htmlEditorUiStore' |
| 22 | import { useToastStore } from '../../store/toastStore' |
| 23 | |
| 24 | const iconBtnClass = |
| 25 | 'app-no-drag rounded-md p-1.5 text-[#5d6b4d] transition-colors hover:bg-[#ece5d6] disabled:pointer-events-none disabled:opacity-40' |
| 26 | |
| 27 | /** HTML 编辑器顶部工具条(全图标,tooltip 标注)。 */ |
| 28 | export function HtmlEditorToolbar({ onOpenHistory }: { onOpenHistory: () => void }): ReactElement { |
| 29 | const t = useT() |
| 30 | const navigate = useNavigate() |
| 31 | const isMac = window.electron?.process?.platform === 'darwin' |
| 32 | |
| 33 | const docId = useHtmlEditorStore((s) => s.docId) |
| 34 | const title = useHtmlEditorStore((s) => s.title) |
| 35 | const sourcePath = useHtmlEditorStore((s) => s.sourcePath) |
| 36 | const exporting = useHtmlEditorStore((s) => s.exporting) |
| 37 | const isSavingEdits = useHtmlEditStore((s) => s.isSavingEdits) |
| 38 | const canUndo = useHtmlEditHistoryStore((s) => (docId ? s.canUndo(docId) : false)) |
| 39 | const canRedo = useHtmlEditHistoryStore((s) => (docId ? s.canRedo(docId) : false)) |
| 40 | const hasPending = useHtmlEditHistoryStore((s) => (docId ? s.hasPendingEdits(docId) : false)) |
| 41 | const aiModeEnabled = useHtmlEditorAiStore((s) => s.enabled) |
| 42 | |
| 43 | const displayName = |
| 44 | title || (sourcePath ? sourcePath.split(/[\\/]/).pop() : '') || t('htmlEditor.untitled') |
| 45 | |
| 46 | const handleSave = useCallback(async (): Promise<void> => { |
| 47 | await useHtmlEditStore.getState().save() |
| 48 | }, []) |
| 49 | |
| 50 | const handleExport = useCallback(async (): Promise<void> => { |
| 51 | const saved = await useHtmlEditStore.getState().save() |
| 52 | if (saved.error) return |
| 53 | const path = await useHtmlEditorStore.getState().exportAs() |
| 54 | if (path) useToastStore.getState().success(t('htmlEditor.exported')) |
| 55 | }, [t]) |
| 56 | |
| 57 | const handlePreview = useCallback(async (): Promise<void> => { |
| 58 | const saved = await useHtmlEditStore.getState().save() |
| 59 | if (saved.error || !docId) return |
| 60 | await ipc.openHtmlInBrowser({ docId }) |
| 61 | }, [docId]) |
| 62 | |
| 63 | const handleRevealFile = useCallback(async (): Promise<void> => { |
| 64 | if (!docId) return |
| 65 | const result = await ipc.revealHtmlFile({ docId }) |
| 66 | if (!result.ok) useToastStore.getState().error(t('htmlEditor.revealFileFailed')) |
| 67 | }, [docId, t]) |
| 68 | |
| 69 | const handleUndo = useCallback((): void => { |
| 70 | useHtmlEditStore.getState().undo() |
| 71 | }, []) |
| 72 | const handleRedo = useCallback((): void => { |
| 73 | useHtmlEditStore.getState().redo() |
| 74 | }, []) |
| 75 | const handleDiscardAll = useCallback((): void => { |
| 76 | useHtmlEditStore.getState().discardAll() |
| 77 | }, []) |
| 78 | |
| 79 | const handleToggleAiMode = useCallback(async (): Promise<void> => { |
| 80 | if (isSavingEdits) return |
| 81 | const nextEnabled = !useHtmlEditorAiStore.getState().enabled |
| 82 | if (nextEnabled && docId) { |
| 83 | const editStore = useHtmlEditStore.getState() |
| 84 | editStore.commitCurrentDraft() |
| 85 | if (useHtmlEditHistoryStore.getState().hasPendingEdits(docId)) { |
| 86 | const saved = await editStore.save() |
| 87 | if (!saved.saved) return |
| 88 | } |
| 89 | } |
| 90 | useHtmlEditorAiStore.getState().setEnabled(nextEnabled) |
| 91 | useHtmlEditorUiStore.getState().clearSelectedElement() |
| 92 | }, [docId, isSavingEdits]) |
| 93 | |
| 94 | const tipBtn = ( |
| 95 | Icon: typeof Home, |
| 96 | label: string, |
| 97 | onClick: () => void, |
| 98 | disabled?: boolean, |
| 99 | danger?: boolean |
| 100 | ) => ( |
| 101 | <Tooltip> |
| 102 | <TooltipTrigger asChild> |
| 103 | <button |
| 104 | type="button" |
| 105 | onClick={onClick} |
| 106 | disabled={disabled} |
| 107 | aria-label={label} |
| 108 | className={`${iconBtnClass} ${danger ? 'text-[#8e5a53] hover:bg-[#f3e6e2]' : ''}`} |
| 109 | > |
| 110 | <Icon className="h-4 w-4" /> |
| 111 | </button> |
| 112 | </TooltipTrigger> |
| 113 | <TooltipContent side="bottom">{label}</TooltipContent> |
| 114 | </Tooltip> |
| 115 | ) |
| 116 | |
| 117 | return ( |
| 118 | <header className="app-drag-region app-titlebar relative shrink-0 bg-[#f5f1e8]/95 shadow-[0_10px_26px_rgba(93,107,77,0.055)] backdrop-blur-xl"> |
| 119 | <div |
| 120 | className={`relative flex h-full items-center gap-1.5 ${ |
| 121 | isMac ? 'pl-[85px]' : 'pl-4' |
| 122 | } ${isMac ? '' : 'pr-[calc(var(--app-titlebar-control-safe-area)+16px)]'}`} |
| 123 | > |
| 124 | {/* 返回 */} |
| 125 | <Tooltip> |
| 126 | <TooltipTrigger asChild> |
| 127 | <button |
| 128 | type="button" |
| 129 | onClick={() => navigate('/edit-html')} |
| 130 | className="app-no-drag inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] bg-[#e8e0d0]/72 text-[#3e4a32] shadow-[0_4px_10px_rgba(86,72,53,0.08)] transition-colors hover:bg-[#d4e4c1]/78" |
| 131 | > |
| 132 | <Home className="h-3.5 w-3.5" /> |
| 133 | </button> |
| 134 | </TooltipTrigger> |
| 135 | <TooltipContent side="bottom">{t('htmlEditor.backToList')}</TooltipContent> |
| 136 | </Tooltip> |
| 137 | |
| 138 | {/* 标题 */} |
| 139 | <Tooltip> |
| 140 | <TooltipTrigger asChild> |
| 141 | <div className="flex w-[150px] shrink-0 items-center gap-2 rounded-[10px] bg-[#e8e0d0]/60 px-3 py-1"> |
| 142 | <div className="min-w-0 flex-1 truncate text-[12px] font-medium text-[#3e4a32]"> |
| 143 | {displayName} |
| 144 | </div> |
| 145 | </div> |
| 146 | </TooltipTrigger> |
| 147 | <TooltipContent side="bottom" align="start"> |
| 148 | {displayName} |
| 149 | </TooltipContent> |
| 150 | </Tooltip> |
| 151 | |
| 152 | <span className="mx-0.5 h-4 w-px bg-[#e2dccf]" /> |
| 153 | |
| 154 | {/* 撤销 / 重做 / 撤销所有 / 保存(一组) */} |
| 155 | {tipBtn(Undo2, t('htmlEditor.undo'), handleUndo, !canUndo)} |
| 156 | {tipBtn(Redo2, t('htmlEditor.redo'), handleRedo, !canRedo)} |
| 157 | {tipBtn(RotateCcw, t('htmlEditor.discardAll'), handleDiscardAll, !hasPending, true)} |
| 158 | {tipBtn(Save, t('htmlEditor.save'), () => void handleSave(), !hasPending || isSavingEdits)} |
| 159 | |
| 160 | <span className="mx-0.5 h-4 w-px bg-[#e2dccf]" /> |
| 161 | |
| 162 | {/* 导出 / 查看文件 / 预览 / 版本历史(一组) */} |
| 163 | {tipBtn( |
| 164 | Download, |
| 165 | t('htmlEditor.export'), |
| 166 | () => void handleExport(), |
| 167 | exporting || isSavingEdits |
| 168 | )} |
| 169 | {tipBtn( |
| 170 | FileSearch, |
| 171 | t('htmlEditor.revealFile'), |
| 172 | () => void handleRevealFile(), |
| 173 | !docId || isSavingEdits |
| 174 | )} |
| 175 | {tipBtn( |
| 176 | ExternalLink, |
| 177 | t('htmlEditor.preview'), |
| 178 | () => void handlePreview(), |
| 179 | !docId || isSavingEdits |
| 180 | )} |
| 181 | {tipBtn(History, t('htmlEditor.history'), onOpenHistory, !docId)} |
| 182 | <button |
| 183 | type="button" |
| 184 | onClick={() => void handleToggleAiMode()} |
| 185 | disabled={!docId || isSavingEdits} |
| 186 | className={`app-no-drag ml-1 rounded-md px-2 py-1 text-[12px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-40 ${ |
| 187 | aiModeEnabled ? 'bg-[#dbe7ca] text-[#2f3b28]' : 'text-[#5d6b4d] hover:bg-[#ece5d6]' |
| 188 | }`} |
| 189 | title={t('htmlEditor.aiModeButton')} |
| 190 | > |
| 191 | {t('htmlEditor.aiModeButton')} |
| 192 | </button> |
| 193 | </div> |
| 194 | </header> |
| 195 | ) |
| 196 | } |
| 197 |