import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import MenuBookRoundedIcon from '@mui/icons-material/MenuBookRounded'; import { alert } from '@app/components/toast'; import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; import EditTableOfContentsWorkbenchView, { EditTableOfContentsWorkbenchViewData } from '@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView'; import EditTableOfContentsSettings from '@app/components/tools/editTableOfContents/EditTableOfContentsSettings'; import { useEditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters'; import { useEditTableOfContentsOperation } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation'; import { BaseToolProps, ToolComponent } from '@app/types/tool'; import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; import apiClient from '@app/services/apiClient'; import { BookmarkPayload, BookmarkNode, hydrateBookmarkPayload, serializeBookmarkNodes } from '@app/utils/editTableOfContents'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; import { useFileSelection } from '@app/contexts/FileContext'; const extractBookmarks = async (file: File): Promise => { const formData = new FormData(); formData.append('file', file); const response = await apiClient.post('/api/v1/general/extract-bookmarks', formData, { headers: { 'Content-Type': 'multipart/form-data' }, }); return response.data as BookmarkPayload[]; }; const useStableCallback = any>(callback: T): T => { const callbackRef = useRef(callback); useEffect(() => { callbackRef.current = callback; }, [callback]); return useMemo(() => ((...args: Parameters) => callbackRef.current(...args)) as T, []); }; const EditTableOfContents = (props: BaseToolProps) => { const { t } = useTranslation(); const base = useBaseTool( 'edit-table-of-contents', useEditTableOfContentsParameters, useEditTableOfContentsOperation, props, { minFiles: 1 } ); const { registerCustomWorkbenchView, unregisterCustomWorkbenchView, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, } = useToolWorkflow(); const { openFilesModal } = useFilesModalContext(); const { clearSelections } = useFileSelection(); const navigationState = useNavigationState(); const { actions: navigationActions } = useNavigationActions(); const WORKBENCH_VIEW_ID = 'editTableOfContentsWorkbench'; const WORKBENCH_ID = 'custom:editTableOfContents' as const; const viewIcon = useMemo(() => , []); const [loadError, setLoadError] = useState(null); const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false); const [lastLoadedFileId, setLastLoadedFileId] = useState(null); const hasAutoOpenedWorkbenchRef = useRef(false); const selectedFile = base.selectedFiles[0]; const { setBookmarks } = base.params; useEffect(() => { registerCustomWorkbenchView({ id: WORKBENCH_VIEW_ID, workbenchId: WORKBENCH_ID, label: 'Outline workspace', icon: viewIcon, component: EditTableOfContentsWorkbenchView, }); return () => { clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); }; // Register once; avoid re-registering which clears data mid-flight }, []); const loadBookmarksForFile = useCallback(async (file: File, { showToast }: { showToast?: boolean } = {}) => { setIsLoadingBookmarks(true); setLoadError(null); try { const payload = await extractBookmarks(file); const bookmarks = hydrateBookmarkPayload(payload); setBookmarks(bookmarks); setLastLoadedFileId((file as any)?.fileId ?? file.name); if (showToast) { alert({ title: t('editTableOfContents.messages.loadedTitle', 'Bookmarks extracted'), body: t('editTableOfContents.messages.loadedBody', 'Existing bookmarks from the PDF were loaded into the editor.'), alertType: 'success', }); } if (bookmarks.length === 0) { setLoadError(t('editTableOfContents.messages.noBookmarks', 'No bookmarks were found in the selected PDF.')); } } catch (error) { console.error('Failed to load bookmarks', error); setLoadError(t('editTableOfContents.messages.loadFailed', 'Unable to extract bookmarks from the selected PDF.')); } finally { setIsLoadingBookmarks(false); } }, [setBookmarks, t]); useEffect(() => { // Don't auto-load bookmarks if we have results - user is viewing the output if (base.hasResults) { return; } if (!selectedFile) { setBookmarks([]); setLastLoadedFileId(null); setLoadError(null); return; } const fileId = (selectedFile as any)?.fileId ?? selectedFile.name; if (fileId === lastLoadedFileId) { return; } loadBookmarksForFile(selectedFile).catch(() => { // errors handled in hook }); }, [selectedFile, lastLoadedFileId, loadBookmarksForFile, setBookmarks, base.hasResults]); const importJsonCallback = async (file: File) => { try { const text = await file.text(); const json = JSON.parse(text) as BookmarkPayload[]; setBookmarks(hydrateBookmarkPayload(json)); alert({ title: t('editTableOfContents.messages.imported', 'Bookmarks imported'), body: t('editTableOfContents.messages.importedBody', 'Your JSON outline replaced the current editor contents.'), alertType: 'success', }); } catch (error) { console.error('Failed to import JSON bookmarks', error); alert({ title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'), body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'), alertType: 'error', }); } }; const handleImportJson = useStableCallback(importJsonCallback); const importClipboardCallback = async () => { if (!navigator.clipboard?.readText) { alert({ title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'), alertType: 'warning', }); return; } try { const clipboard = await navigator.clipboard.readText(); const json = JSON.parse(clipboard) as BookmarkPayload[]; setBookmarks(hydrateBookmarkPayload(json)); alert({ title: t('editTableOfContents.messages.imported', 'Bookmarks imported'), body: t('editTableOfContents.messages.importedClipboard', 'Clipboard data replaced the current bookmark list.'), alertType: 'success', }); } catch (error) { console.error('Failed to import bookmarks from clipboard', error); alert({ title: t('editTableOfContents.messages.invalidJson', 'Invalid JSON structure'), body: t('editTableOfContents.messages.invalidJsonBody', 'Please provide a valid bookmark JSON file and try again.'), alertType: 'error', }); } }; const handleImportClipboard = useStableCallback(importClipboardCallback); const exportJsonCallback = () => { const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2); const blob = new Blob([data], { type: 'application/json' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'bookmarks.json'; document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); URL.revokeObjectURL(url); alert({ title: t('editTableOfContents.messages.exported', 'JSON download ready'), alertType: 'success', }); }; const handleExportJson = useStableCallback(exportJsonCallback); const exportClipboardCallback = async () => { if (!navigator.clipboard?.writeText) { alert({ title: t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access unavailable'), alertType: 'warning', }); return; } const data = JSON.stringify(serializeBookmarkNodes(base.params.parameters.bookmarks), null, 2); try { await navigator.clipboard.writeText(data); alert({ title: t('editTableOfContents.messages.copied', 'Copied to clipboard'), body: t('editTableOfContents.messages.copiedBody', 'Bookmark JSON copied successfully.'), alertType: 'success', }); } catch (error) { console.error('Failed to copy bookmarks', error); alert({ title: t('editTableOfContents.messages.copyFailed', 'Copy failed'), alertType: 'error', }); } }; const handleExportClipboard = useStableCallback(exportClipboardCallback); const clipboardReadAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.readText); const clipboardWriteAvailable = typeof navigator !== 'undefined' && Boolean(navigator.clipboard?.writeText); const loadFromSelectedCallback = () => { if (selectedFile) { loadBookmarksForFile(selectedFile, { showToast: true }); } }; const handleLoadFromSelected = useStableCallback(loadFromSelectedCallback); const replaceExistingCallback = (value: boolean) => { base.params.updateParameter('replaceExisting', value); }; const handleReplaceExistingChange = useStableCallback(replaceExistingCallback); const bookmarksChangeCallback = (bookmarks: BookmarkNode[]) => { setBookmarks(bookmarks); }; const handleBookmarksChange = useStableCallback(bookmarksChangeCallback); const executeCallback = () => { void base.handleExecute(); }; const handleExecute = useStableCallback(executeCallback); const undoCallback = () => { base.handleUndo(); }; const handleUndo = useStableCallback(undoCallback); const clearErrorCallback = () => { base.operation.clearError(); }; const handleClearError = useStableCallback(clearErrorCallback); const fileClickCallback = (file: File) => { base.handleThumbnailClick(file); }; const handleFileClick = useStableCallback(fileClickCallback); const selectFilesCallback = () => { // Clear existing selection first so the new file replaces instead of adds clearSelections(); openFilesModal(); }; const handleSelectFiles = useStableCallback(selectFilesCallback); // Always keep workbench data updated useEffect(() => { const data: EditTableOfContentsWorkbenchViewData = { bookmarks: base.params.parameters.bookmarks, selectedFileName: selectedFile?.name, disabled: base.endpointLoading || base.operation.isLoading, files: base.operation.files ?? [], thumbnails: base.operation.thumbnails ?? [], downloadUrl: base.operation.downloadUrl ?? null, downloadFilename: base.operation.downloadFilename ?? null, errorMessage: base.operation.errorMessage ?? null, isGeneratingThumbnails: base.operation.isGeneratingThumbnails, isExecuteDisabled: !selectedFile || !base.hasFiles || base.endpointEnabled === false || base.operation.isLoading || base.endpointLoading, isExecuting: base.operation.isLoading, onClearError: handleClearError, onBookmarksChange: handleBookmarksChange, onExecute: handleExecute, onUndo: handleUndo, onFileClick: handleFileClick, }; setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, data); }, [ WORKBENCH_VIEW_ID, base.endpointEnabled, base.endpointLoading, base.hasFiles, base.operation.downloadFilename, base.operation.downloadUrl, base.operation.errorMessage, base.operation.files, base.operation.isGeneratingThumbnails, base.operation.isLoading, base.operation.thumbnails, base.params.parameters.bookmarks, handleBookmarksChange, handleClearError, handleExecute, handleFileClick, handleUndo, selectedFile, setCustomWorkbenchViewData, ]); // Auto-navigate to workbench when tool is selected useEffect(() => { if (navigationState.selectedTool !== 'editTableOfContents') { hasAutoOpenedWorkbenchRef.current = false; return; } if (hasAutoOpenedWorkbenchRef.current) { return; } hasAutoOpenedWorkbenchRef.current = true; // Use timeout to ensure data effect has run first setTimeout(() => { navigationActions.setWorkbench(WORKBENCH_ID); }, 0); }, [navigationActions, navigationState.selectedTool, WORKBENCH_ID]); return createToolFlow({ files: { selectedFiles: base.selectedFiles, isCollapsed: true, minFiles: 1, isVisible: false, }, steps: [ { title: t('editTableOfContents.settings.title', 'Bookmarks & outline'), isCollapsed: false, content: ( ), }, ], review: { isVisible: base.hasResults, operation: base.operation, title: t('editTableOfContents.results.title', 'Updated PDF with bookmarks'), onFileClick: base.handleThumbnailClick, onUndo: handleUndo, }, }); }; (EditTableOfContents as any).tool = () => useEditTableOfContentsOperation; export default EditTableOfContents as ToolComponent;