Split reimplementation

This commit is contained in:
Reece 2025-07-10 01:48:13 +01:00
parent 666c15fabd
commit c7dce8a68f
5 changed files with 444 additions and 292 deletions

View File

@ -11,7 +11,6 @@ import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import DragDropGrid from '../pageEditor/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
@ -29,11 +28,21 @@ interface FileItem {
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void;
toolMode?: boolean;
multiSelect?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
onFileSelect?: (files: File[]) => void;
}
const FileEditor = ({
onOpenPageEditor,
onMergeFiles
onMergeFiles,
toolMode = false,
multiSelect = true,
showUpload = true,
showBulkActions = true,
onFileSelect
}: FileEditorProps) => {
const { t } = useTranslation();
@ -54,8 +63,14 @@ const FileEditor = ({
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localLoading, setLocalLoading] = useState(false);
const [csvInput, setCsvInput] = useState<string>('');
const [selectionMode, setSelectionMode] = useState(false);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
React.useEffect(() => {
if (toolMode) {
setSelectionMode(true);
}
}, [toolMode]);
const [draggedFile, setDraggedFile] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
@ -183,53 +198,52 @@ const FileEditor = ({
const toggleFile = useCallback((fileId: string) => {
const fileName = files.find(f => f.id === fileId)?.name || fileId;
if (!multiSelect) {
// Single select mode for tools - toggle on/off
const isCurrentlySelected = selectedFileIds.includes(fileName);
if (isCurrentlySelected) {
// Deselect the file
setContextSelectedFiles([]);
if (onFileSelect) {
onFileSelect([]);
}
} else {
// Select the file
setContextSelectedFiles([fileName]);
const selectedFile = files.find(f => f.id === fileId)?.file;
if (selectedFile && onFileSelect) {
onFileSelect([selectedFile]);
}
}
} else {
// Multi select mode (default)
setContextSelectedFiles(prev =>
prev.includes(fileName)
? prev.filter(id => id !== fileName)
: [...prev, fileName]
);
}, [files, setContextSelectedFiles]);
// Notify parent with selected files
if (onFileSelect) {
const selectedFiles = files
.filter(f => selectedFileIds.includes(f.name) || f.name === fileName)
.map(f => f.file);
onFileSelect(selectedFiles);
}
}
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, selectedFileIds]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setContextSelectedFiles([]);
setCsvInput('');
}
return newMode;
});
}, [setContextSelectedFiles]);
const parseCSVInput = useCallback((csv: string) => {
const fileNames: string[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end && i <= files.length; i++) {
if (i > 0) {
const file = files[i - 1];
if (file) fileNames.push(file.name);
}
}
} else {
const fileIndex = parseInt(range);
if (fileIndex > 0 && fileIndex <= files.length) {
const file = files[fileIndex - 1];
if (file) fileNames.push(file.name);
}
}
});
return fileNames;
}, [files]);
const updateFilesFromCSV = useCallback(() => {
const fileNames = parseCSVInput(csvInput);
setContextSelectedFiles(fileNames);
}, [csvInput, parseCSVInput, setContextSelectedFiles]);
// Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => {
@ -401,22 +415,7 @@ const FileEditor = ({
<Box p="md" pt="xl">
<Group mb="md">
<Button
onClick={toggleSelectionMode}
variant={selectionMode ? "filled" : "outline"}
color={selectionMode ? "blue" : "gray"}
styles={{
root: {
transition: 'all 0.2s ease',
...(selectionMode && {
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
})
}
}}
>
{selectionMode ? "Exit Selection" : "Select Files"}
</Button>
{selectionMode && (
{showBulkActions && !toolMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
@ -424,6 +423,8 @@ const FileEditor = ({
)}
{/* Load from storage and upload buttons */}
{showUpload && (
<>
<Button
variant="outline"
color="blue"
@ -443,16 +444,10 @@ const FileEditor = ({
Upload Files
</Button>
</Dropzone>
</>
)}
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={localSelectedFiles}
onUpdatePagesFromCSV={updateFilesFromCSV}
/>
)}
{files.length === 0 && !localLoading ? (
<Center h="60vh">
@ -530,6 +525,7 @@ const FileEditor = ({
onMergeFromHere={handleMergeFromHere}
onSplitFile={handleSplitFile}
onSetStatus={setStatus}
toolMode={toolMode}
/>
)}
renderSplitMarker={(file, index) => (

View File

@ -38,6 +38,7 @@ interface FileThumbnailProps {
onMergeFromHere: (fileId: string) => void;
onSplitFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
toolMode?: boolean;
}
const FileThumbnail = ({
@ -62,6 +63,7 @@ const FileThumbnail = ({
onMergeFromHere,
onSplitFile,
onSetStatus,
toolMode = false,
}: FileThumbnailProps) => {
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
@ -238,6 +240,8 @@ const FileThumbnail = ({
whiteSpace: 'nowrap'
}}
>
{!toolMode && (
<>
<Tooltip label="View File">
<ActionIcon
size="md"
@ -282,6 +286,8 @@ const FileThumbnail = ({
<SplitscreenIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</>
)}
<Tooltip label="Delete File">
<ActionIcon

View File

@ -10,6 +10,7 @@ interface ToolRendererProps {
setDownloadUrl: (url: string | null) => void;
toolParams: any;
updateParams: (params: any) => void;
toolSelectedFiles?: File[];
}
const ToolRenderer = ({
@ -21,6 +22,7 @@ const ToolRenderer = ({
setDownloadUrl,
toolParams,
updateParams,
toolSelectedFiles = [],
}: ToolRendererProps) => {
if (!selectedTool || !selectedTool.component) {
return <div>Tool not found</div>;
@ -33,11 +35,9 @@ const ToolRenderer = ({
case "split":
return (
<ToolComponent
file={pdfFile}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
selectedFiles={toolSelectedFiles}
/>
);
case "compress":

View File

@ -36,7 +36,7 @@ type ToolRegistry = {
// Base tool registry without translations
const baseToolRegistry = {
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "viewer" },
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "split" },
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "viewer" },
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "pageEditor" },
};
@ -60,6 +60,8 @@ export default function HomePage() {
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false);
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
const [toolParams, setToolParams] = useState<Record<string, any>>({});
// Tool registry
const toolRegistry: ToolRegistry = {
@ -68,23 +70,27 @@ export default function HomePage() {
merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") },
};
// Tool parameters (simplified for now)
// Tool parameters with state management
const getToolParams = (toolKey: string | null) => {
if (!toolKey) return {};
// Get stored params for this tool, or use defaults
const storedParams = toolParams[toolKey] || {};
const defaultParams = (() => {
switch (toolKey) {
case 'split':
return {
mode: 'grid',
mode: '',
pages: '',
hDiv: 2,
vDiv: 2,
hDiv: '2',
vDiv: '2',
merge: false,
splitType: 'pages',
splitValue: 1,
bookmarkLevel: 1,
includeMetadata: true,
allowDuplicates: false
splitType: 'size',
splitValue: '',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
};
case 'compress':
return {
@ -100,8 +106,21 @@ export default function HomePage() {
default:
return {};
}
})();
return { ...defaultParams, ...storedParams };
};
const updateToolParams = useCallback((toolKey: string, newParams: any) => {
setToolParams(prev => ({
...prev,
[toolKey]: {
...prev[toolKey],
...newParams
}
}));
}, []);
useEffect(() => {
const activeFileData = activeFiles.map(file => ({
@ -364,7 +383,8 @@ export default function HomePage() {
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
toolParams={getToolParams(selectedToolKey)}
updateParams={() => {}}
updateParams={(newParams) => updateToolParams(selectedToolKey, newParams)}
toolSelectedFiles={toolSelectedFiles}
/>
</div>
</div>
@ -461,6 +481,27 @@ export default function HomePage() {
/>
)}
</>
) : currentView === "split" ? (
<FileEditor
toolMode={true}
multiSelect={false}
showUpload={true}
showBulkActions={true}
onFileSelect={(files) => {
setToolSelectedFiles(files);
}}
/>
) : selectedToolKey && selectedTool ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
selectedTool={selectedTool}
pdfFile={activeFiles[0] || null}
files={activeFiles}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
toolParams={getToolParams(selectedToolKey)}
updateParams={() => {}}
/>
) : (
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FileUploadSelector

View File

@ -8,16 +8,17 @@ import {
Notification,
Stack,
Paper,
Text,
Alert,
Box,
Group,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { FileWithUrl } from "../types/file";
import { fileStorage } from "../services/fileStorage";
import { useFileContext } from "../contexts/FileContext";
import FileEditor from "../components/fileEditor/FileEditor";
export interface SplitPdfPanelProps {
file: { file: FileWithUrl; url: string } | null;
downloadUrl?: string | null;
setDownloadUrl: (url: string | null) => void;
params: {
mode: string;
pages: string;
@ -31,20 +32,22 @@ export interface SplitPdfPanelProps {
allowDuplicates: boolean;
};
updateParams: (newParams: Partial<SplitPdfPanelProps["params"]>) => void;
selectedFiles?: File[];
}
const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
file,
downloadUrl,
setDownloadUrl,
params,
updateParams,
selectedFiles = [],
}) => {
const { t } = useTranslation();
const fileContext = useFileContext();
const { activeFiles, selectedFileIds, updateProcessedFile } = fileContext;
const [status, setStatus] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const {
mode,
@ -59,30 +62,30 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
allowDuplicates,
} = params;
// Clear download when parameters or files change
React.useEffect(() => {
if (downloadUrl) {
setDownloadUrl(null);
setStatus("");
}
// Reset step 2 completion when parameters change (but not when just status/loading changes)
setStep2Completed(false);
}, [mode, pages, hDiv, vDiv, merge, splitType, splitValue, bookmarkLevel, includeMetadata, allowDuplicates, selectedFiles]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
if (selectedFiles.length === 0) {
setStatus(t("noFileSelected"));
return;
}
const formData = new FormData();
// Handle IndexedDB files
if (!file.file.id) {
setStatus(t("noFileSelected"));
return;
}
const storedFile = await fileStorage.getFile(file.file.id);
if (storedFile) {
const blob = new Blob([storedFile.data], { type: storedFile.type });
const actualFile = new File([blob], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
// Use selected files from context
selectedFiles.forEach(file => {
formData.append("fileInput", file);
});
formData.append("fileInput", actualFile);
}
let endpoint = "";
@ -140,11 +143,85 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
}
};
// Check if current mode needs additional parameters
const modeNeedsParams = (currentMode: string) => {
return currentMode && currentMode !== ""; // All modes need some params
};
// Step 2 completion state
const [step2Completed, setStep2Completed] = useState(false);
// Check if step 2 settings are valid (for enabling Done button)
const step2SettingsValid = (() => {
if (!mode) return false;
switch (mode) {
case "byPages":
return pages.trim() !== "";
case "bySections":
return hDiv !== "" && vDiv !== "";
case "bySizeOrCount":
return splitType !== "" && splitValue.trim() !== "";
case "byChapters":
return bookmarkLevel !== "";
default:
return false;
}
})();
// Determine what steps to show
const showStep1 = true; // Always show - Files
const showStep2 = selectedFiles.length > 0; // Settings (mode + params)
const showStep3 = step2Completed; // Review (apply & continue vs export)
// Determine if steps are collapsed (completed)
const step1Collapsed = selectedFiles.length > 0;
const step2Collapsed = step2Completed;
return (
<form onSubmit={handleSubmit} className="app-surface p-app-md rounded-app-md">
<Stack gap="sm" mb={16}>
<Box h="100%" p="md" style={{ overflow: 'auto' }}>
<Stack gap="md">
{/* Step 1: Files */}
{showStep1 && (
<Paper p="md" withBorder>
<Text fw={500} size="lg" mb="sm">1. Files</Text>
{step1Collapsed ? (
<Text size="sm" c="green">
Selected: {selectedFiles[0]?.name}
</Text>
) : (
<Text size="sm" c="dimmed">
Select a PDF file in the main view to get started
</Text>
)}
</Paper>
)}
{/* Step 2: Settings */}
{showStep2 && (
<Paper
p="md"
withBorder
style={{
cursor: step2Collapsed ? 'pointer' : 'default',
opacity: step2Collapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
onClick={step2Collapsed ? () => {
// Go back to step 2
setStep2Completed(false);
} : undefined}
>
<Text fw={500} size="lg" mb="sm">2. Settings</Text>
{step2Collapsed ? (
<Text size="sm" c="green">
Settings configured <Text span c="dimmed" size="xs">(click to change)</Text>
</Text>
) : (
<Stack gap="md">
<Select
label={t("split-by-size-or-count.type.label", "Split Mode")}
label="Choose split method"
placeholder="Select how to split the PDF"
value={mode}
onChange={(v) => v && updateParams({ mode: v })}
data={[
@ -155,6 +232,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
]}
/>
{/* Mode-specific Parameters */}
{mode === "byPages" && (
<TextInput
label={t("split.splitPages", "Pages")}
@ -234,19 +312,46 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
</Stack>
)}
<Button type="submit" loading={isLoading} fullWidth>
{/* Done Button */}
{mode && (
<Button
fullWidth
mt="md"
disabled={!step2SettingsValid}
onClick={() => setStep2Completed(true)}
>
Done
</Button>
)}
</Stack>
)}
</Paper>
)}
{/* Step 3: Review */}
{showStep3 && (
<Paper p="md" withBorder>
<Text fw={500} size="lg" mb="sm">3. Review</Text>
<form onSubmit={handleSubmit}>
<Button
type="submit"
loading={isLoading}
fullWidth
disabled={selectedFiles.length === 0}
>
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
</Button>
{status && <p className="text-xs text-text-muted">{status}</p>}
{status && <Text size="xs" c="dimmed" mt="xs">{status}</Text>}
{errorMessage && (
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)}>
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)} mt="sm">
{errorMessage}
</Notification>
)}
{status === t("downloadComplete") && downloadUrl && (
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
@ -254,12 +359,16 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
leftSection={<DownloadIcon />}
color="green"
fullWidth
mt="sm"
>
{t("downloadPdf", "Download Split PDF")}
</Button>
)}
</Stack>
</form>
</Paper>
)}
</Stack>
</Box>
);
};