| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| 2 | import { |
| 3 | useEditHistoryStore, |
| 4 | useEditSessionStore, |
| 5 | useGenerateStore, |
| 6 | useSessionStore, |
| 7 | useSessionDetailRuntimeStore, |
| 8 | useSessionDetailUiStore, |
| 9 | useToastStore, |
| 10 | isStyleSwitchPageLocked, |
| 11 | type WorkspaceRibbonRegisteredActions |
| 12 | } from '@renderer/store' |
| 13 | import { useT } from '@renderer/i18n' |
| 14 | import type { SessionWorkspaceTab } from '@renderer/types/session-detail' |
| 15 | import { isPageGenerationLocked, normalizePagesForSelection } from '../shared' |
| 16 | import type { WorkspaceRibbonState } from '../workspace/toolbar/types' |
| 17 | |
| 18 | export function useWorkspaceRibbonActionsRegistration( |
| 19 | actions: WorkspaceRibbonRegisteredActions |
| 20 | ): void { |
| 21 | const actionsRef = useRef(actions) |
| 22 | const setWorkspaceRibbonActions = useSessionDetailRuntimeStore( |
| 23 | (state) => state.setWorkspaceRibbonActions |
| 24 | ) |
| 25 | |
| 26 | useEffect(() => { |
| 27 | actionsRef.current = actions |
| 28 | }, [actions]) |
| 29 | |
| 30 | useEffect(() => { |
| 31 | const registeredActions: WorkspaceRibbonRegisteredActions = { |
| 32 | onUndo: () => actionsRef.current.onUndo(), |
| 33 | onRedo: () => actionsRef.current.onRedo(), |
| 34 | onSaveCurrentPage: () => actionsRef.current.onSaveCurrentPage(), |
| 35 | onDiscardAllEdits: () => actionsRef.current.onDiscardAllEdits(), |
| 36 | onApplySelectedToAllPages: () => actionsRef.current.onApplySelectedToAllPages(), |
| 37 | onCopySelectedElement: () => actionsRef.current.onCopySelectedElement(), |
| 38 | onDeleteSelectedElement: () => actionsRef.current.onDeleteSelectedElement(), |
| 39 | onBackToSessions: () => actionsRef.current.onBackToSessions(), |
| 40 | onAddFromLibrary: (type) => actionsRef.current.onAddFromLibrary(type), |
| 41 | onAddFromLocal: (type) => actionsRef.current.onAddFromLocal(type), |
| 42 | onAddText: () => actionsRef.current.onAddText(), |
| 43 | onAddArtText: (templateId) => actionsRef.current.onAddArtText(templateId), |
| 44 | onAddShape: (type) => actionsRef.current.onAddShape(type), |
| 45 | onAddIcon: (iconId) => actionsRef.current.onAddIcon(iconId), |
| 46 | onAddChart: (type) => actionsRef.current.onAddChart(type), |
| 47 | onAddFormula: () => actionsRef.current.onAddFormula() |
| 48 | } |
| 49 | setWorkspaceRibbonActions(registeredActions) |
| 50 | return () => setWorkspaceRibbonActions(null) |
| 51 | }, [setWorkspaceRibbonActions]) |
| 52 | } |
| 53 | |
| 54 | export function useWorkspaceRibbonController(isSavingEdits: boolean): { |
| 55 | selectedPageKey: string | null |
| 56 | state: WorkspaceRibbonState |
| 57 | activateTab: (tab: SessionWorkspaceTab) => void |
| 58 | pendingTab: SessionWorkspaceTab | null |
| 59 | pendingTabLabel: string |
| 60 | savingBeforeTabSwitch: boolean |
| 61 | cancelPendingTab: () => void |
| 62 | discardPendingTab: () => void |
| 63 | confirmPendingTab: () => Promise<void> |
| 64 | } { |
| 65 | const t = useT() |
| 66 | const [pendingTab, setPendingTab] = useState<SessionWorkspaceTab | null>(null) |
| 67 | const [savingBeforeTabSwitch, setSavingBeforeTabSwitch] = useState(false) |
| 68 | const isGenerating = useGenerateStore((state) => state.isGenerating) |
| 69 | const currentSession = useSessionStore((state) => state.currentSession) |
| 70 | const pageEditJob = useGenerateStore((state) => |
| 71 | currentSession ? state.pageEditJobs[currentSession.id] || null : null |
| 72 | ) |
| 73 | const pageBeautifyJob = useGenerateStore((state) => |
| 74 | currentSession ? state.pageBeautifyJobs[currentSession.id] || null : null |
| 75 | ) |
| 76 | const isDeckEditing = useGenerateStore((state) => |
| 77 | currentSession ? Boolean(state.deckEditJobs[currentSession.id]) : false |
| 78 | ) |
| 79 | const styleSwitchJob = useGenerateStore((state) => |
| 80 | currentSession ? state.styleSwitchJobs[currentSession.id] || null : null |
| 81 | ) |
| 82 | const currentPages = useGenerateStore((state) => state.currentPages) |
| 83 | const toastInfo = useToastStore((state) => state.info) |
| 84 | const selectedPageId = useSessionDetailUiStore((state) => state.selectedPageId) |
| 85 | const isAddingPage = useSessionDetailUiStore((state) => state.isAddingPage) |
| 86 | const addingPageId = useSessionDetailUiStore((state) => state.addingPageId) |
| 87 | const isRetryingSinglePage = useSessionDetailUiStore((state) => state.isRetryingSinglePage) |
| 88 | const retryingSinglePageId = useSessionDetailUiStore((state) => state.retryingSinglePageId) |
| 89 | const activeTab = useSessionDetailUiStore((state) => state.workspaceTab) |
| 90 | const setActiveTab = useSessionDetailUiStore((state) => state.setWorkspaceTab) |
| 91 | const bumpPreviewKey = useSessionDetailUiStore((state) => state.bumpPreviewKey) |
| 92 | const interactionMode = useSessionDetailUiStore((state) => state.interactionMode) |
| 93 | const setInteractionMode = useSessionDetailUiStore((state) => state.setInteractionMode) |
| 94 | const clearSelectedElement = useSessionDetailUiStore((state) => state.clearSelectedElement) |
| 95 | const setSpeechScriptDialogOpen = useSessionDetailUiStore( |
| 96 | (state) => state.setSpeechScriptDialogOpen |
| 97 | ) |
| 98 | const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages]) |
| 99 | const selectedPage = useMemo( |
| 100 | () => pages.find((page) => page.id === selectedPageId) ?? pages[0] ?? null, |
| 101 | [pages, selectedPageId] |
| 102 | ) |
| 103 | |
| 104 | const selectedPageKey = selectedPage?.htmlPath |
| 105 | ? `${selectedPage.pageId}:${selectedPage.htmlPath}` |
| 106 | : null |
| 107 | const pageId = selectedPage?.pageId |
| 108 | const styleSwitchPageLocked = isStyleSwitchPageLocked(styleSwitchJob, pageId) |
| 109 | const isPageEditing = pageEditJob?.pageId === pageId |
| 110 | const isPageBeautifying = pageBeautifyJob?.pageId === pageId |
| 111 | const isScopedGeneration = |
| 112 | isAddingPage || |
| 113 | isRetryingSinglePage || |
| 114 | Boolean(pageEditJob) || |
| 115 | Boolean(pageBeautifyJob) || |
| 116 | Boolean(styleSwitchJob) |
| 117 | const isCurrentPageGenerating = |
| 118 | isGenerating && |
| 119 | (!isScopedGeneration || |
| 120 | isPageGenerationLocked(selectedPage?.id, { |
| 121 | isAddingPage, |
| 122 | addingPageId, |
| 123 | isRetryingSinglePage, |
| 124 | retryingSinglePageId |
| 125 | }) || |
| 126 | isPageEditing || |
| 127 | isPageBeautifying || |
| 128 | styleSwitchPageLocked) |
| 129 | const canUndo = useEditHistoryStore((state) => state.canUndo(pageId)) |
| 130 | const canRedo = useEditHistoryStore((state) => state.canRedo(pageId)) |
| 131 | const hasPendingEdits = useEditHistoryStore((state) => state.hasPendingEdits(pageId)) |
| 132 | const tabLabels = useMemo<Record<SessionWorkspaceTab, string>>( |
| 133 | () => ({ |
| 134 | preview: t('sessionDetail.previewMode'), |
| 135 | edit: t('sessionDetail.editMode'), |
| 136 | browse: t('sessionDetail.browseMode'), |
| 137 | style: t('sessionDetail.styleMode'), |
| 138 | animation: t('sessionDetail.animationTab'), |
| 139 | speech: t('sessionDetail.speechScript'), |
| 140 | ai: t('sessionDetail.aiMode') |
| 141 | }), |
| 142 | [t] |
| 143 | ) |
| 144 | const pendingTabLabel = pendingTab ? tabLabels[pendingTab] : '' |
| 145 | |
| 146 | const applyTab = useCallback( |
| 147 | (tab: SessionWorkspaceTab): void => { |
| 148 | if (activeTab === 'animation' && tab !== 'animation') { |
| 149 | clearSelectedElement() |
| 150 | bumpPreviewKey() |
| 151 | } |
| 152 | setActiveTab(tab) |
| 153 | if (tab === 'preview') { |
| 154 | setInteractionMode('preview') |
| 155 | setSpeechScriptDialogOpen(false) |
| 156 | return |
| 157 | } |
| 158 | if (tab === 'browse' || tab === 'style') { |
| 159 | setInteractionMode('preview') |
| 160 | setSpeechScriptDialogOpen(false) |
| 161 | return |
| 162 | } |
| 163 | if (tab === 'speech') { |
| 164 | clearSelectedElement() |
| 165 | setInteractionMode('preview') |
| 166 | setSpeechScriptDialogOpen(true) |
| 167 | return |
| 168 | } |
| 169 | if (tab === 'animation') { |
| 170 | useEditSessionStore.getState().cancelEdit() |
| 171 | clearSelectedElement() |
| 172 | setInteractionMode('animation-select') |
| 173 | setSpeechScriptDialogOpen(false) |
| 174 | toastInfo(t('sessionDetail.animationModeToast')) |
| 175 | return |
| 176 | } |
| 177 | if (tab === 'ai') { |
| 178 | clearSelectedElement() |
| 179 | setInteractionMode('ai-inspect') |
| 180 | setSpeechScriptDialogOpen(false) |
| 181 | toastInfo(t('sessionDetail.inspectActiveToast')) |
| 182 | return |
| 183 | } |
| 184 | if (interactionMode !== 'edit') { |
| 185 | setInteractionMode('edit') |
| 186 | } |
| 187 | setSpeechScriptDialogOpen(false) |
| 188 | toastInfo(t('sessionDetail.editModeToast')) |
| 189 | }, |
| 190 | [ |
| 191 | clearSelectedElement, |
| 192 | activeTab, |
| 193 | bumpPreviewKey, |
| 194 | interactionMode, |
| 195 | setActiveTab, |
| 196 | setInteractionMode, |
| 197 | setSpeechScriptDialogOpen, |
| 198 | t, |
| 199 | toastInfo |
| 200 | ] |
| 201 | ) |
| 202 | |
| 203 | const activateTab = useCallback( |
| 204 | (tab: SessionWorkspaceTab): void => { |
| 205 | if (tab === activeTab) return |
| 206 | const canSwitchWithoutSave = tab === 'browse' || tab === 'edit' || tab === 'preview' |
| 207 | if (!canSwitchWithoutSave) { |
| 208 | useEditSessionStore.getState().commitCurrentDraft() |
| 209 | } |
| 210 | const hasCurrentPendingEdits = useEditHistoryStore.getState().hasPendingEdits(pageId) |
| 211 | if (!canSwitchWithoutSave && (hasPendingEdits || hasCurrentPendingEdits)) { |
| 212 | setPendingTab(tab) |
| 213 | return |
| 214 | } |
| 215 | applyTab(tab) |
| 216 | }, |
| 217 | [activeTab, applyTab, hasPendingEdits, pageId] |
| 218 | ) |
| 219 | |
| 220 | const cancelPendingTab = useCallback((): void => { |
| 221 | if (savingBeforeTabSwitch) return |
| 222 | setPendingTab(null) |
| 223 | }, [savingBeforeTabSwitch]) |
| 224 | |
| 225 | const discardPendingTab = useCallback((): void => { |
| 226 | const tab = pendingTab |
| 227 | if (!tab || savingBeforeTabSwitch) return |
| 228 | useEditSessionStore.getState().discardAll() |
| 229 | if (pageId && useEditHistoryStore.getState().hasPendingEdits(pageId)) { |
| 230 | useEditHistoryStore.getState().clearPage(pageId) |
| 231 | } |
| 232 | setPendingTab(null) |
| 233 | applyTab(tab) |
| 234 | }, [applyTab, pageId, pendingTab, savingBeforeTabSwitch]) |
| 235 | |
| 236 | const confirmPendingTab = useCallback(async (): Promise<void> => { |
| 237 | const tab = pendingTab |
| 238 | if (!tab || savingBeforeTabSwitch) return |
| 239 | setSavingBeforeTabSwitch(true) |
| 240 | try { |
| 241 | const result = await useEditSessionStore.getState().save() |
| 242 | const stillPending = useEditHistoryStore.getState().hasPendingEdits(pageId) |
| 243 | if (result.saved || !stillPending) { |
| 244 | setPendingTab(null) |
| 245 | applyTab(tab) |
| 246 | } |
| 247 | } finally { |
| 248 | setSavingBeforeTabSwitch(false) |
| 249 | } |
| 250 | }, [applyTab, pageId, pendingTab, savingBeforeTabSwitch]) |
| 251 | |
| 252 | const state: WorkspaceRibbonState = useMemo( |
| 253 | () => ({ |
| 254 | isGenerating: isCurrentPageGenerating, |
| 255 | isPageEditing, |
| 256 | isPageBeautifying, |
| 257 | isDeckEditing, |
| 258 | isStyleSwitchPageLocked: styleSwitchPageLocked, |
| 259 | isSavingEdits, |
| 260 | canUndo, |
| 261 | canRedo, |
| 262 | hasPendingEdits, |
| 263 | activeTab |
| 264 | }), |
| 265 | [ |
| 266 | activeTab, |
| 267 | canRedo, |
| 268 | canUndo, |
| 269 | hasPendingEdits, |
| 270 | isDeckEditing, |
| 271 | styleSwitchPageLocked, |
| 272 | isCurrentPageGenerating, |
| 273 | isPageEditing, |
| 274 | isPageBeautifying, |
| 275 | isSavingEdits |
| 276 | ] |
| 277 | ) |
| 278 | |
| 279 | return { |
| 280 | selectedPageKey, |
| 281 | state, |
| 282 | activateTab, |
| 283 | pendingTab, |
| 284 | pendingTabLabel, |
| 285 | savingBeforeTabSwitch, |
| 286 | cancelPendingTab, |
| 287 | discardPendingTab, |
| 288 | confirmPendingTab |
| 289 | } |
| 290 | } |
| 291 |