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 <[email protected]>
This commit is contained in:
Anthony Stirling
2025-11-18 15:07:12 +00:00
committed by GitHub
co-authored by Reece Browne
parent a8ea0b60cf
commit 87bf7a5b7f
12 changed files with 1413 additions and 20 deletions
@@ -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>
);
}
@@ -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>
);
}
@@ -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;