Add edit table of contents tool to React UI (#4917)

## Summary
- add a dedicated edit table of contents tool to the React UI, complete
with bookmark editor, import/export actions, and parameter handling
- register the tool in the translated registry and extend the English
translations with the new strings
- wire up the backend endpoints through a new operation hook and
form-data serialization helpers

## Testing
- ./gradlew build

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_691a4a87a9c4832899ecd1c55989f27f)

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
This commit is contained in:
Anthony Stirling 2025-11-18 15:07:12 +00:00 committed by GitHub
parent a8ea0b60cf
commit 87bf7a5b7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1413 additions and 20 deletions

View File

@ -29,7 +29,6 @@ import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
@ -49,13 +48,12 @@ public class EditTableOfContentsController {
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
@RequestParam("file") MultipartFile file) throws Exception {
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
@ -63,10 +61,10 @@ public class EditTableOfContentsController {
if (outline == null) {
log.info("No outline/bookmarks found in PDF");
return new ArrayList<>();
return ResponseEntity.ok(new ArrayList<>());
}
return extractBookmarkItems(document, outline);
return ResponseEntity.ok(extractBookmarkItems(document, outline));
} finally {
if (document != null) {
document.close();

View File

@ -24,6 +24,7 @@ import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
@ -86,9 +87,13 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@ -108,9 +113,13 @@ class EditTableOfContentsControllerTest {
when(mockCatalog.getDocumentOutline()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertTrue(result.isEmpty());
verify(mockDocument).close();
@ -142,9 +151,13 @@ class EditTableOfContentsControllerTest {
when(childItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@ -178,9 +191,13 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());

View File

@ -1427,6 +1427,93 @@
},
"submit": "Change"
},
"editTableOfContents": {
"settings": {
"title": "Bookmarks & outline",
"replaceExisting": "Replace existing bookmarks (uncheck to append)",
"replaceExistingHint": "When disabled, the new outline is appended after the current bookmarks."
},
"actions": {
"source": "Load bookmarks",
"selectedFile": "Loaded from {{file}}",
"noFile": "Select a PDF to extract existing bookmarks.",
"loadFromPdf": "Load from selected PDF",
"importJson": "Import JSON",
"importClipboard": "Paste JSON from clipboard",
"export": "Export bookmarks",
"exportJson": "Download JSON",
"exportClipboard": "Copy JSON to clipboard",
"clipboardUnavailable": "Clipboard access is not available in this browser."
},
"info": {
"line1": "Each bookmark needs a descriptive title and the page it should open.",
"line2": "Use child bookmarks to build a hierarchy for chapters, sections, or subsections.",
"line3": "Import bookmarks from the selected PDF or from a JSON file to save time."
},
"workbench": {
"empty": {
"title": "Open the tool to start editing",
"description": "Select the Edit Table of Contents tool to load its workspace."
},
"tabTitle": "Outline workspace",
"subtitle": "Import bookmarks, build hierarchies, and apply the outline without cramped side panels.",
"noFile": "No PDF selected",
"fileLabel": "Changes will be applied to the currently selected PDF.",
"filePrompt": "Select a PDF from your library or upload a new one to begin.",
"changeFile": "Change PDF",
"selectFile": "Select PDF"
},
"editor": {
"heading": "Bookmark editor",
"description": "Add, nest, and reorder bookmarks to craft your PDF outline.",
"addTopLevel": "Add top-level bookmark",
"empty": {
"title": "No bookmarks yet",
"description": "Import existing bookmarks or start by adding your first entry.",
"action": "Add first bookmark"
},
"defaultTitle": "New bookmark",
"defaultChildTitle": "Child bookmark",
"defaultSiblingTitle": "New bookmark",
"untitled": "Untitled bookmark",
"childBadge": "Child",
"pagePreview": "Page {{page}}",
"field": {
"title": "Bookmark title",
"page": "Target page number"
},
"actions": {
"toggle": "Toggle children",
"addChild": "Add child bookmark",
"addSibling": "Add sibling bookmark",
"remove": "Remove bookmark"
},
"confirmRemove": "Remove this bookmark and all of its children?"
},
"messages": {
"loadedTitle": "Bookmarks extracted",
"loadedBody": "Existing bookmarks from the PDF were loaded into the editor.",
"noBookmarks": "No bookmarks were found in the selected PDF.",
"loadFailed": "Unable to extract bookmarks from the selected PDF.",
"imported": "Bookmarks imported",
"importedBody": "Your JSON outline replaced the current editor contents.",
"importedClipboard": "Clipboard data replaced the current bookmark list.",
"invalidJson": "Invalid JSON structure",
"invalidJsonBody": "Please provide a valid bookmark JSON file and try again.",
"exported": "JSON download ready",
"copied": "Copied to clipboard",
"copiedBody": "Bookmark JSON copied successfully.",
"copyFailed": "Copy failed"
},
"error": {
"failed": "Failed to update the table of contents"
},
"submit": "Apply table of contents",
"results": {
"title": "Updated PDF with bookmarks",
"subtitle": "Download the processed file or undo the operation below."
}
},
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",

View File

@ -1397,6 +1397,76 @@
},
"submit": "Change"
},
"editTableOfContents": {
"settings": {
"title": "Bookmarks & outline",
"replaceExisting": "Replace existing bookmarks (uncheck to append)",
"replaceExistingHint": "When disabled, the new outline is appended after the current bookmarks."
},
"actions": {
"source": "Load bookmarks",
"selectedFile": "Loaded from {{file}}",
"noFile": "Select a PDF to extract existing bookmarks.",
"loadFromPdf": "Load from selected PDF",
"importJson": "Import JSON",
"importClipboard": "Paste JSON from clipboard",
"export": "Export bookmarks",
"exportJson": "Download JSON",
"exportClipboard": "Copy JSON to clipboard",
"clipboardUnavailable": "Clipboard access is not available in this browser."
},
"info": {
"line1": "Each bookmark needs a descriptive title and the page it should open.",
"line2": "Use child bookmarks to build a hierarchy for chapters, sections, or subsections.",
"line3": "Import bookmarks from the selected PDF or from a JSON file to save time."
},
"editor": {
"heading": "Bookmark editor",
"description": "Add, nest, and reorder bookmarks to craft your PDF outline.",
"addTopLevel": "Add top-level bookmark",
"empty": {
"title": "No bookmarks yet",
"description": "Import existing bookmarks or start by adding your first entry.",
"action": "Add first bookmark"
},
"defaultTitle": "New bookmark",
"defaultChildTitle": "Child bookmark",
"defaultSiblingTitle": "New bookmark",
"untitled": "Untitled bookmark",
"childBadge": "Child",
"pagePreview": "Page {{page}}",
"field": {
"title": "Bookmark title",
"page": "Target page number"
},
"actions": {
"toggle": "Toggle children",
"addChild": "Add child bookmark",
"addSibling": "Add sibling bookmark",
"remove": "Remove bookmark"
},
"confirmRemove": "Remove this bookmark and all of its children?"
},
"messages": {
"loadedTitle": "Bookmarks extracted",
"loadedBody": "Existing bookmarks from the PDF were loaded into the editor.",
"noBookmarks": "No bookmarks were found in the selected PDF.",
"loadFailed": "Unable to extract bookmarks from the selected PDF.",
"imported": "Bookmarks imported",
"importedBody": "Your JSON outline replaced the current editor contents.",
"importedClipboard": "Clipboard data replaced the current bookmark list.",
"invalidJson": "Invalid JSON structure",
"invalidJsonBody": "Please provide a valid bookmark JSON file and try again.",
"exported": "JSON download ready",
"copied": "Copied to clipboard",
"copiedBody": "Bookmark JSON copied successfully.",
"copyFailed": "Copy failed"
},
"submit": "Apply table of contents",
"results": {
"title": "Updated PDF with bookmarks"
}
},
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",

View File

@ -0,0 +1,313 @@
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActionIcon,
Badge,
Button,
Flex,
Group,
NumberInput,
Paper,
Stack,
Text,
TextInput,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode, createBookmarkNode } from '@app/utils/editTableOfContents';
interface BookmarkEditorProps {
bookmarks: BookmarkNode[];
onChange: (bookmarks: BookmarkNode[]) => void;
disabled?: boolean;
}
const updateTree = (
nodes: BookmarkNode[],
targetId: string,
updater: (bookmark: BookmarkNode) => BookmarkNode,
): BookmarkNode[] => {
return nodes.map(node => {
if (node.id === targetId) {
return updater(node);
}
if (node.children.length === 0) {
return node;
}
const updatedChildren = updateTree(node.children, targetId, updater);
if (updatedChildren !== node.children) {
return { ...node, children: updatedChildren };
}
return node;
});
};
const removeFromTree = (nodes: BookmarkNode[], targetId: string): BookmarkNode[] => {
return nodes
.filter(node => node.id !== targetId)
.map(node => ({
...node,
children: removeFromTree(node.children, targetId),
}));
};
const addChildToTree = (
nodes: BookmarkNode[],
parentId: string,
child: BookmarkNode,
): { nodes: BookmarkNode[]; added: boolean } => {
let added = false;
const next = nodes.map(node => {
if (node.id === parentId) {
added = true;
return { ...node, expanded: true, children: [...node.children, child] };
}
if (node.children.length === 0) {
return node;
}
const result = addChildToTree(node.children, parentId, child);
if (result.added) {
added = true;
return { ...node, children: result.nodes };
}
return node;
});
return { nodes: added ? next : nodes, added };
};
const addSiblingInTree = (
nodes: BookmarkNode[],
targetId: string,
sibling: BookmarkNode,
): { nodes: BookmarkNode[]; added: boolean } => {
let added = false;
const result: BookmarkNode[] = [];
nodes.forEach(node => {
let currentNode = node;
if (!added && node.children.length > 0) {
const childResult = addSiblingInTree(node.children, targetId, sibling);
if (childResult.added) {
added = true;
currentNode = { ...node, children: childResult.nodes };
}
}
result.push(currentNode);
if (!added && node.id === targetId) {
result.push(sibling);
added = true;
}
});
return { nodes: added ? result : nodes, added };
};
export default function BookmarkEditor({ bookmarks, onChange, disabled }: BookmarkEditorProps) {
const { t } = useTranslation();
const handleAddTopLevel = () => {
const newBookmark = createBookmarkNode({ title: t('editTableOfContents.editor.defaultTitle', 'New bookmark') });
onChange([...bookmarks, newBookmark]);
};
const handleTitleChange = (id: string, value: string) => {
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, title: value })));
};
const handlePageChange = (id: string, value: number | string) => {
const page = typeof value === 'number' ? value : parseInt(value, 10);
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, pageNumber: Number.isFinite(page) && page > 0 ? page : 1 })));
};
const handleToggle = (id: string) => {
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, expanded: !bookmark.expanded })));
};
const handleRemove = (id: string) => {
const confirmation = t(
'editTableOfContents.editor.confirmRemove',
'Remove this bookmark and all of its children?'
);
if (window.confirm(confirmation)) {
onChange(removeFromTree(bookmarks, id));
}
};
const handleAddChild = (parentId: string) => {
const child = createBookmarkNode({ title: t('editTableOfContents.editor.defaultChildTitle', 'Child bookmark') });
const { nodes, added } = addChildToTree(bookmarks, parentId, child);
onChange(added ? nodes : [...bookmarks, child]);
};
const handleAddSibling = (targetId: string) => {
const sibling = createBookmarkNode({ title: t('editTableOfContents.editor.defaultSiblingTitle', 'New bookmark') });
const { nodes, added } = addSiblingInTree(bookmarks, targetId, sibling);
onChange(added ? nodes : [...bookmarks, sibling]);
};
const renderBookmark = (bookmark: BookmarkNode, level = 0) => {
const hasChildren = bookmark.children.length > 0;
const chevronIcon = bookmark.expanded ? 'expand-more-rounded' : 'chevron-right-rounded';
return (
<Paper
key={bookmark.id}
radius="md"
withBorder
p="md"
style={{
borderColor: 'var(--border-default)',
background: level === 0 ? 'var(--bg-surface)' : 'var(--bg-muted)',
}}
>
<Stack gap="sm">
<Flex align="flex-start" justify="space-between" gap="md">
<Group gap="sm" align="flex-start">
<ActionIcon
variant="subtle"
color="gray"
onClick={() => hasChildren && handleToggle(bookmark.id)}
disabled={disabled || !hasChildren}
aria-label={t('editTableOfContents.editor.actions.toggle', 'Toggle children')}
style={{ marginTop: 4 }}
>
<LocalIcon icon={chevronIcon} />
</ActionIcon>
<Stack gap={2}>
<Group gap="xs" align="center">
<Text fw={600}>{bookmark.title || t('editTableOfContents.editor.untitled', 'Untitled bookmark')}</Text>
{level > 0 && (
<Badge size="xs" variant="light" color="blue">
{t('editTableOfContents.editor.childBadge', 'Child')}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t('editTableOfContents.editor.pagePreview', { page: bookmark.pageNumber })}
</Text>
</Stack>
</Group>
<Group gap="xs">
<Tooltip label={t('editTableOfContents.editor.actions.addChild', 'Add child bookmark')}>
<ActionIcon
variant="subtle"
color="green"
onClick={() => handleAddChild(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="subdirectory-arrow-right-rounded" />
</ActionIcon>
</Tooltip>
<Tooltip label={t('editTableOfContents.editor.actions.addSibling', 'Add sibling bookmark')}>
<ActionIcon
variant="subtle"
color="blue"
onClick={() => handleAddSibling(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="add-rounded" />
</ActionIcon>
</Tooltip>
<Tooltip label={t('editTableOfContents.editor.actions.remove', 'Remove bookmark')}>
<ActionIcon
variant="subtle"
color="red"
onClick={() => handleRemove(bookmark.id)}
disabled={disabled}
>
<LocalIcon icon="delete-rounded" />
</ActionIcon>
</Tooltip>
</Group>
</Flex>
{bookmark.expanded && (
<Stack gap="sm">
<TextInput
size="sm"
label={t('editTableOfContents.editor.field.title', 'Bookmark title')}
value={bookmark.title}
onChange={event => handleTitleChange(bookmark.id, event.currentTarget.value)}
disabled={disabled}
/>
<NumberInput
size="sm"
label={t('editTableOfContents.editor.field.page', 'Target page number')}
min={1}
clampBehavior="strict"
value={bookmark.pageNumber}
onChange={value => handlePageChange(bookmark.id, value ?? 1)}
disabled={disabled}
/>
</Stack>
)}
{bookmark.expanded && hasChildren && (
<Stack gap="sm" pl="lg" style={{ borderLeft: '1px solid var(--border-default)' }}>
{bookmark.children.map(child => (
<Fragment key={child.id}>{renderBookmark(child, level + 1)}</Fragment>
))}
</Stack>
)}
</Stack>
</Paper>
);
};
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.editor.description', 'Add, nest, and reorder bookmarks to craft your PDF outline.')}
</Text>
</div>
<Button
variant="default"
color="blue"
leftSection={<LocalIcon icon="bookmark-add-rounded" />}
onClick={handleAddTopLevel}
disabled={disabled}
>
{t('editTableOfContents.editor.addTopLevel', 'Add top-level bookmark')}
</Button>
</Group>
{bookmarks.length === 0 ? (
<Paper withBorder radius="md" ta="center" py="xl">
<Stack gap="xs" align="center" px="lg">
<LocalIcon icon="bookmark-add-rounded" style={{ fontSize: '2.25rem' }} />
<Text fw={600}>{t('editTableOfContents.editor.empty.title', 'No bookmarks yet')}</Text>
<Text size="sm" c="dimmed" maw={420}>
{t('editTableOfContents.editor.empty.description', 'Import existing bookmarks or start by adding your first entry.')}
</Text>
<Button
variant="subtle"
color="blue"
leftSection={<LocalIcon icon="add-rounded" />}
onClick={handleAddTopLevel}
disabled={disabled}
>
{t('editTableOfContents.editor.empty.action', 'Add first bookmark')}
</Button>
</Stack>
</Paper>
) : (
<Stack gap="sm">
{bookmarks.map(bookmark => renderBookmark(bookmark))}
</Stack>
)}
</Stack>
);
}

View File

@ -0,0 +1,187 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
Button,
Divider,
FileButton,
Stack,
Switch,
Text,
Tooltip,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode } from '@app/utils/editTableOfContents';
interface EditTableOfContentsSettingsProps {
bookmarks: BookmarkNode[];
replaceExisting: boolean;
onReplaceExistingChange: (value: boolean) => void;
onSelectFiles: () => void;
onLoadFromPdf: () => void;
onImportJson: (file: File) => void;
onImportClipboard: () => void;
onExportJson: () => void;
onExportClipboard: () => void;
isLoading: boolean;
loadError?: string | null;
canReadClipboard: boolean;
canWriteClipboard: boolean;
disabled?: boolean;
selectedFileName?: string;
}
export default function EditTableOfContentsSettings({
bookmarks,
replaceExisting,
onReplaceExistingChange,
onSelectFiles,
onLoadFromPdf,
onImportJson,
onImportClipboard,
onExportJson,
onExportClipboard,
isLoading,
loadError,
canReadClipboard,
canWriteClipboard,
disabled,
selectedFileName,
}: EditTableOfContentsSettingsProps) {
const { t } = useTranslation();
const infoLines = useMemo(() => ([
t('editTableOfContents.info.line1', 'Each bookmark needs a descriptive title and the page it should open.'),
t('editTableOfContents.info.line2', 'Use child bookmarks to build a hierarchy for chapters, sections, or subsections.'),
t('editTableOfContents.info.line3', 'Import bookmarks from the selected PDF or from a JSON file to save time.'),
]), [t]);
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>{t('editTableOfContents.actions.source', 'Load bookmarks')}</Text>
<Text size="xs" c="dimmed">
{selectedFileName
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
: t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.')}
</Text>
</Stack>
<Stack gap="sm">
<Button
variant="light"
leftSection={<LocalIcon icon="folder-rounded" />}
onClick={onSelectFiles}
fullWidth
>
{selectedFileName
? t('editTableOfContents.workbench.changeFile', 'Change PDF')
: t('editTableOfContents.workbench.selectFile', 'Select PDF')}
</Button>
<Tooltip label={!selectedFileName ? t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.') : ''} disabled={Boolean(selectedFileName)}>
<Button
variant="default"
leftSection={<LocalIcon icon="picture-as-pdf-rounded" />}
onClick={onLoadFromPdf}
loading={isLoading}
disabled={disabled || !selectedFileName}
fullWidth
>
{t('editTableOfContents.actions.loadFromPdf', 'Load from PDF')}
</Button>
</Tooltip>
<FileButton
onChange={file => file && onImportJson(file)}
accept="application/json"
disabled={disabled}
>
{(props) => (
<Button
{...props}
variant="default"
leftSection={<LocalIcon icon="upload-rounded" />}
disabled={disabled}
fullWidth
>
{t('editTableOfContents.actions.importJson', 'Import JSON')}
</Button>
)}
</FileButton>
<Tooltip
label={canReadClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
disabled={canReadClipboard}
>
<Button
variant="default"
leftSection={<LocalIcon icon="content-paste-rounded" />}
onClick={onImportClipboard}
disabled={disabled || !canReadClipboard}
fullWidth
>
{t('editTableOfContents.actions.importClipboard', 'Paste from clipboard')}
</Button>
</Tooltip>
</Stack>
{loadError && (
<Alert color="red" radius="md" icon={<LocalIcon icon="error-outline-rounded" />}>
{loadError}
</Alert>
)}
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>{t('editTableOfContents.actions.export', 'Export bookmarks')}</Text>
</Stack>
<Stack gap="sm">
<Button
variant="default"
leftSection={<LocalIcon icon="download-rounded" />}
onClick={onExportJson}
disabled={disabled || bookmarks.length === 0}
fullWidth
>
{t('editTableOfContents.actions.exportJson', 'Download JSON')}
</Button>
<Tooltip
label={canWriteClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
disabled={canWriteClipboard}
>
<Button
variant="default"
leftSection={<LocalIcon icon="content-copy-rounded" />}
onClick={onExportClipboard}
disabled={disabled || bookmarks.length === 0 || !canWriteClipboard}
fullWidth
>
{t('editTableOfContents.actions.exportClipboard', 'Copy to clipboard')}
</Button>
</Tooltip>
</Stack>
<Divider />
<Switch
checked={replaceExisting}
onChange={(event) => onReplaceExistingChange(event.currentTarget.checked)}
label={t('editTableOfContents.settings.replaceExisting', 'Replace existing bookmarks')}
description={t('editTableOfContents.settings.replaceExistingHint', 'When disabled, the new outline is appended after the current bookmarks.')}
disabled={disabled}
/>
<Stack gap="xs">
{infoLines.map((line, index) => (
<Text key={index} size="sm" c="dimmed">
{line}
</Text>
))}
</Stack>
</Stack>
);
}

View File

@ -0,0 +1,203 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Button,
Card,
Divider,
Group,
Stack,
Text,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { BookmarkNode } from '@app/utils/editTableOfContents';
import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
export interface EditTableOfContentsWorkbenchViewData {
bookmarks: BookmarkNode[];
selectedFileName?: string;
disabled: boolean;
files: File[];
thumbnails: (string | undefined)[];
downloadUrl: string | null;
downloadFilename: string | null;
errorMessage: string | null;
isGeneratingThumbnails: boolean;
isExecuteDisabled: boolean;
isExecuting: boolean;
onClearError: () => void;
onBookmarksChange: (bookmarks: BookmarkNode[]) => void;
onExecute: () => void;
onUndo: () => void;
onFileClick: (file: File) => void;
}
interface EditTableOfContentsWorkbenchViewProps {
data: EditTableOfContentsWorkbenchViewData | null;
}
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
const { t } = useTranslation();
if (!data) {
return (
<Box p="xl">
<Card withBorder radius="md">
<Stack gap="sm">
<Text fw={600}>{t('editTableOfContents.workbench.empty.title', 'Open the tool to start editing')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.workbench.empty.description', 'Select the Edit Table of Contents tool to load its workspace.')}
</Text>
</Stack>
</Card>
</Box>
);
}
const {
bookmarks,
selectedFileName,
disabled,
files,
thumbnails,
downloadUrl,
downloadFilename,
errorMessage,
isGeneratingThumbnails,
isExecuteDisabled,
isExecuting,
onClearError,
onBookmarksChange,
onExecute,
onUndo,
onFileClick,
} = data;
const previewFiles = useMemo(
() =>
files?.map((file, index) => ({
file,
thumbnail: thumbnails[index],
})) ?? [],
[files, thumbnails]
);
const showResults = Boolean(
previewFiles.length > 0 || downloadUrl || errorMessage
);
return (
<Box
p="lg"
style={{
width: '100%',
height: '100%',
overflowY: 'auto',
background: 'var(--bg-raised)',
}}
>
<Stack gap="xl" maw={1200} mx="auto">
<Stack gap={4}>
<Text size="xl" fw={700}>
{t('home.editTableOfContents.title', 'Edit Table of Contents')}
</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.workbench.subtitle', 'Import bookmarks, build hierarchies, and apply the outline without cramped side panels.')}
</Text>
</Stack>
<Card
withBorder
radius="md"
p="xl"
style={{
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
boxShadow: 'var(--shadow-md)',
}}
>
<Stack gap="md">
<Stack gap={2}>
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
<Text size="sm" c="dimmed">
{selectedFileName
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
: t('editTableOfContents.workbench.filePrompt', 'Select a PDF from your library or upload a new one to begin.')}
</Text>
</Stack>
<BookmarkEditor bookmarks={bookmarks} onChange={onBookmarksChange} disabled={disabled} />
<Divider />
<Group justify="flex-end">
<Button
leftSection={<LocalIcon icon="menu-book-rounded" />}
color="blue"
onClick={onExecute}
disabled={isExecuteDisabled}
loading={isExecuting}
>
{t('editTableOfContents.submit', 'Apply table of contents')}
</Button>
</Group>
</Stack>
</Card>
{showResults && (
<Card
withBorder
radius="md"
p="xl"
style={{
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
boxShadow: 'var(--shadow-md)',
}}
>
<Stack gap="md">
<Stack gap={4}>
<Text fw={600}>{t('editTableOfContents.results.title', 'Updated PDF with bookmarks')}</Text>
<Text size="sm" c="dimmed">
{t('editTableOfContents.results.subtitle', 'Download the processed file or undo the operation below.')}
</Text>
</Stack>
<ErrorNotification error={errorMessage} onClose={onClearError} />
{previewFiles.length > 0 && (
<ResultsPreview
files={previewFiles}
onFileClick={onFileClick}
isGeneratingThumbnails={isGeneratingThumbnails}
/>
)}
<Group justify="flex-end" gap="sm">
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
download={downloadFilename ?? undefined}
leftSection={<LocalIcon icon='download-rounded' />}
>
{t('download', 'Download')}
</Button>
)}
<Button
variant="outline"
leftSection={<LocalIcon icon="rotate-left" />}
onClick={onUndo}
disabled={isExecuting}
>
{t('undo', 'Undo')}
</Button>
</Group>
</Stack>
</Card>
)}
</Stack>
</Box>
);
};
export default EditTableOfContentsWorkbenchView;

View File

@ -31,6 +31,7 @@ import AddWatermark from "@app/tools/AddWatermark";
import AddStamp from "@app/tools/AddStamp";
import AddAttachments from "@app/tools/AddAttachments";
import Merge from '@app/tools/Merge';
import EditTableOfContents from '@app/tools/EditTableOfContents';
import Repair from "@app/tools/Repair";
import AutoRename from "@app/tools/AutoRename";
import SingleLargePage from "@app/tools/SingleLargePage";
@ -63,6 +64,7 @@ import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermiss
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
import { editTableOfContentsOperationConfig } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
import { autoRenameOperationConfig } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
import { flattenOperationConfig } from "@app/hooks/tools/flatten/useFlattenOperation";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
@ -345,6 +347,23 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
automationSettings: ChangeMetadataSingleStep,
synonyms: getSynonyms(t, "changeMetadata")
},
editTableOfContents: {
icon: <LocalIcon icon="toc-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: EditTableOfContents,
description: t(
"home.editTableOfContents.desc",
"Add or edit bookmarks and table of contents in PDF documents"
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
maxFiles: 1,
endpoints: ["edit-table-of-contents"],
operationConfig: editTableOfContentsOperationConfig,
automationSettings: null,
supportsAutomate: false,
synonyms: getSynonyms(t, "editTableOfContents"),
},
// Page Formatting
crop: {
@ -689,16 +708,6 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
synonyms: getSynonyms(t, "addImage"),
automationSettings: null
},
editTableOfContents: {
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.editTableOfContents.title", "Edit Table of Contents"),
component: null,
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "editTableOfContents"),
automationSettings: null
},
scannerEffect: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.scannerEffect.title", "Scanner Effect"),

View File

@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { ToolType, type ToolOperationConfig, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { EditTableOfContentsParameters } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters';
import { serializeBookmarkNodes } from '@app/utils/editTableOfContents';
const buildFormData = (parameters: EditTableOfContentsParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('replaceExisting', String(parameters.replaceExisting));
formData.append('bookmarkData', JSON.stringify(serializeBookmarkNodes(parameters.bookmarks)));
return formData;
};
export const editTableOfContentsOperationConfig: ToolOperationConfig<EditTableOfContentsParameters> = {
toolType: ToolType.singleFile,
operationType: 'editTableOfContents',
endpoint: '/api/v1/general/edit-table-of-contents',
buildFormData,
};
export const useEditTableOfContentsOperation = () => {
const { t } = useTranslation();
return useToolOperation<EditTableOfContentsParameters>({
...editTableOfContentsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('editTableOfContents.error.failed', 'Failed to update the table of contents')
),
});
};

View File

@ -0,0 +1,37 @@
import { useCallback } from 'react';
import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
import { BookmarkNode } from '@app/utils/editTableOfContents';
export interface EditTableOfContentsParameters {
replaceExisting: boolean;
bookmarks: BookmarkNode[];
}
export interface EditTableOfContentsParametersHook extends BaseParametersHook<EditTableOfContentsParameters> {
setBookmarks: (bookmarks: BookmarkNode[]) => void;
}
const defaultParameters: EditTableOfContentsParameters = {
replaceExisting: true,
bookmarks: [],
};
export const useEditTableOfContentsParameters = (): EditTableOfContentsParametersHook => {
const base = useBaseParameters<EditTableOfContentsParameters>({
defaultParameters,
endpointName: 'edit-table-of-contents',
});
const setBookmarks = useCallback((bookmarks: BookmarkNode[]) => {
base.setParameters(prev => ({
...prev,
bookmarks,
}));
}, [base.setParameters]);
return {
...base,
setBookmarks,
};
};

View File

@ -0,0 +1,394 @@
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<BookmarkPayload[]> => {
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 = <T extends (...args: any[]) => any>(callback: T): T => {
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useMemo(() => ((...args: Parameters<T>) => 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(() => <MenuBookRoundedIcon fontSize="small" />, []);
const [loadError, setLoadError] = useState<string | null>(null);
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false);
const [lastLoadedFileId, setLastLoadedFileId] = useState<string | null>(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: (
<EditTableOfContentsSettings
bookmarks={base.params.parameters.bookmarks}
replaceExisting={base.params.parameters.replaceExisting}
onReplaceExistingChange={handleReplaceExistingChange}
onSelectFiles={handleSelectFiles}
onLoadFromPdf={handleLoadFromSelected}
onImportJson={handleImportJson}
onImportClipboard={handleImportClipboard}
onExportJson={handleExportJson}
onExportClipboard={handleExportClipboard}
isLoading={isLoadingBookmarks}
loadError={loadError}
canReadClipboard={clipboardReadAvailable}
canWriteClipboard={clipboardWriteAvailable}
disabled={base.endpointLoading}
selectedFileName={selectedFile?.name}
/>
),
},
],
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;

View File

@ -0,0 +1,47 @@
export interface BookmarkPayload {
title: string;
pageNumber: number;
children?: BookmarkPayload[];
}
export interface BookmarkNode {
id: string;
title: string;
pageNumber: number;
children: BookmarkNode[];
expanded: boolean;
}
const createBookmarkId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
}
return `bookmark-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
};
export const createBookmarkNode = (bookmark?: Partial<BookmarkNode>): BookmarkNode => ({
id: bookmark?.id ?? createBookmarkId(),
title: bookmark?.title ?? '',
pageNumber: bookmark?.pageNumber ?? 1,
children: bookmark?.children ? bookmark.children.map(child => createBookmarkNode(child)) : [],
expanded: bookmark?.expanded ?? true,
});
export const hydrateBookmarkPayload = (payload: BookmarkPayload[] = []): BookmarkNode[] => {
return payload.map(item => ({
id: createBookmarkId(),
title: item.title ?? '',
pageNumber: typeof item.pageNumber === 'number' && item.pageNumber > 0 ? item.pageNumber : 1,
expanded: true,
children: item.children ? hydrateBookmarkPayload(item.children) : [],
}));
};
export const serializeBookmarkNodes = (bookmarks: BookmarkNode[]): BookmarkPayload[] => {
return bookmarks.map(bookmark => ({
title: bookmark.title,
pageNumber: bookmark.pageNumber,
children: serializeBookmarkNodes(bookmark.children),
}));
};