Stirling 2.0 (#3928)

# Description of Changes

<!--

File context for managing files between tools and views
Optimisation for large files
Updated Split to work with new file system and match Matts stepped
design closer

-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
Reece Browne
2025-07-16 17:53:50 +01:00
committed by GitHub
parent 584e2ecee7
commit 922bbc9076
66 changed files with 8728 additions and 2519 deletions

View File

@@ -12,7 +12,7 @@ type ToolRegistry = {
};
interface ToolPickerProps {
selectedToolKey: string;
selectedToolKey: string | null;
onSelect: (id: string) => void;
toolRegistry: ToolRegistry;
}

View File

@@ -1,29 +1,30 @@
import React from "react";
import { FileWithUrl } from "../../types/file";
import { useToolManagement } from "../../hooks/useToolManagement";
interface ToolRendererProps {
selectedToolKey: string;
selectedTool: any;
pdfFile: any;
files: FileWithUrl[];
downloadUrl: string | null;
setDownloadUrl: (url: string | null) => void;
toolParams: any;
updateParams: (params: any) => void;
toolSelectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const ToolRenderer = ({
selectedToolKey,
selectedTool,
pdfFile,
files,
downloadUrl,
setDownloadUrl,
files,
toolParams,
updateParams,
toolSelectedFiles = [],
onPreviewFile,
}: ToolRendererProps) => {
// Get the tool from registry
const { toolRegistry } = useToolManagement();
const selectedTool = toolRegistry[selectedToolKey];
if (!selectedTool || !selectedTool.component) {
return <div>Tool not found</div>;
return <div>Tool not found: {selectedToolKey}</div>;
}
const ToolComponent = selectedTool.component;
@@ -33,19 +34,15 @@ const ToolRenderer = ({
case "split":
return (
<ToolComponent
file={pdfFile}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
selectedFiles={toolSelectedFiles}
onPreviewFile={onPreviewFile}
/>
);
case "compress":
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
setLoading={(loading: boolean) => {}} // TODO: Add loading state
setLoading={(loading: boolean) => {}}
params={toolParams}
updateParams={updateParams}
/>
@@ -54,7 +51,6 @@ const ToolRenderer = ({
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
/>
@@ -63,7 +59,6 @@ const ToolRenderer = ({
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
/>
@@ -71,4 +66,4 @@ const ToolRenderer = ({
}
};
export default ToolRenderer;
export default ToolRenderer;

View File

@@ -0,0 +1,35 @@
import { Notification } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export interface ErrorNotificationProps {
error: string | null;
onClose: () => void;
title?: string;
color?: string;
mb?: string;
}
const ErrorNotification = ({
error,
onClose,
title,
color = 'red',
mb = 'md'
}: ErrorNotificationProps) => {
const { t } = useTranslation();
if (!error) return null;
return (
<Notification
color={color}
title={title || t("error._value", "Error")}
onClose={onClose}
mb={mb}
>
{error}
</Notification>
);
}
export default ErrorNotification;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Text } from '@mantine/core';
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
isCompleted?: boolean;
placeholder?: string;
showFileName?: boolean;
}
const FileStatusIndicator = ({
selectedFiles = [],
isCompleted = false,
placeholder = "Select a PDF file in the main view to get started",
showFileName = true
}: FileStatusIndicatorProps) => {
if (selectedFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
{placeholder}
</Text>
);
}
if (isCompleted) {
return (
<Text size="sm" c="green">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
}
return (
<Text size="sm" c="blue">
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
</Text>
);
}
export default FileStatusIndicator;

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export interface OperationButtonProps {
onClick?: () => void;
isLoading?: boolean;
disabled?: boolean;
loadingText?: string;
submitText?: string;
variant?: 'filled' | 'outline' | 'subtle';
color?: string;
fullWidth?: boolean;
mt?: string;
type?: 'button' | 'submit' | 'reset';
}
const OperationButton = ({
onClick,
isLoading = false,
disabled = false,
loadingText,
submitText,
variant = 'filled',
color = 'blue',
fullWidth = true,
mt = 'md',
type = 'button'
}: OperationButtonProps) => {
const { t } = useTranslation();
return (
<Button
type={type}
onClick={onClick}
fullWidth={fullWidth}
mt={mt}
loading={isLoading}
disabled={disabled}
variant={variant}
color={color}
>
{isLoading
? (loadingText || t("loading", "Loading..."))
: (submitText || t("submit", "Submit"))
}
</Button>
);
}
export default OperationButton;

View File

@@ -0,0 +1,112 @@
import { Grid, Paper, Box, Image, Text, Loader, Stack, Center } from '@mantine/core';
export interface ResultFile {
file: File;
thumbnail?: string;
}
export interface ResultsPreviewProps {
files: ResultFile[];
isGeneratingThumbnails?: boolean;
onFileClick?: (file: File) => void;
title?: string;
emptyMessage?: string;
loadingMessage?: string;
}
const ResultsPreview = ({
files,
isGeneratingThumbnails = false,
onFileClick,
title,
emptyMessage = "No files to preview",
loadingMessage = "Generating previews..."
}: ResultsPreviewProps) => {
const formatSize = (size: number) => {
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${size} B`;
};
if (files.length === 0 && !isGeneratingThumbnails) {
return (
<Text size="sm" c="dimmed">
{emptyMessage}
</Text>
);
}
return (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
{title && (
<Text fw={500} size="md" mb="sm">
{title} ({files.length} files)
</Text>
)}
{isGeneratingThumbnails ? (
<Center p="lg">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">{loadingMessage}</Text>
</Stack>
</Center>
) : (
<Grid>
{files.map((result, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
onClick={() => onFileClick?.(result.file)}
style={{
textAlign: 'center',
height: '10rem',
width:'5rem',
display: 'flex',
flexDirection: 'column',
cursor: onFileClick ? 'pointer' : 'default',
transition: 'all 0.2s ease'
}}
>
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.thumbnail ? (
<Image
src={result.thumbnail}
alt={`Preview of ${result.file.name}`}
style={{
maxWidth: '100%',
maxHeight: '9rem',
objectFit: 'contain'
}}
/>
) : (
<Text size="xs" c="dimmed">No preview</Text>
)}
</Box>
<Text
size="xs"
c="dimmed"
mt="xs"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={result.file.name}
>
{result.file.name}
</Text>
<Text size="xs" c="dimmed">
{formatSize(result.file.size)}
</Text>
</Paper>
</Grid.Col>
))}
</Grid>
)}
</Box>
);
}
export default ResultsPreview;

View File

@@ -0,0 +1,120 @@
import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Paper, Text, Stack, Box } from '@mantine/core';
interface ToolStepContextType {
visibleStepCount: number;
getStepNumber: () => number;
}
const ToolStepContext = createContext<ToolStepContextType | null>(null);
export interface ToolStepProps {
title: string;
isVisible?: boolean;
isCollapsed?: boolean;
isCompleted?: boolean;
onCollapsedClick?: () => void;
children?: React.ReactNode;
completedMessage?: string;
helpText?: string;
showNumber?: boolean;
}
const ToolStep = ({
title,
isVisible = true,
isCollapsed = false,
isCompleted = false,
onCollapsedClick,
children,
completedMessage,
helpText,
showNumber
}: ToolStepProps) => {
if (!isVisible) return null;
// Auto-detect if we should show numbers based on sibling count
const shouldShowNumber = useMemo(() => {
if (showNumber !== undefined) return showNumber;
const parent = useContext(ToolStepContext);
return parent ? parent.visibleStepCount >= 3 : false;
}, [showNumber]);
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
return (
<Paper
p="md"
withBorder
style={{
cursor: isCollapsed && onCollapsedClick ? 'pointer' : 'default',
opacity: isCollapsed ? 0.8 : 1,
transition: 'opacity 0.2s ease'
}}
onClick={isCollapsed && onCollapsedClick ? onCollapsedClick : undefined}
>
<Text fw={500} size="lg" mb="sm">
{shouldShowNumber ? `${stepNumber}. ` : ''}{title}
</Text>
{isCollapsed ? (
<Box>
{isCompleted && completedMessage && (
<Text size="sm" c="green">
{completedMessage}
{onCollapsedClick && (
<Text span c="dimmed" size="xs" ml="sm">
(click to change)
</Text>
)}
</Text>
)}
</Box>
) : (
<Stack gap="md">
{helpText && (
<Text size="sm" c="dimmed">
{helpText}
</Text>
)}
{children}
</Stack>
)}
</Paper>
);
}
export interface ToolStepContainerProps {
children: React.ReactNode;
}
export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
const stepCounterRef = useRef(0);
// Count visible ToolStep children
const visibleStepCount = useMemo(() => {
let count = 0;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const isVisible = child.props.isVisible !== false;
if (isVisible) count++;
}
});
return count;
}, [children]);
const contextValue = useMemo(() => ({
visibleStepCount,
getStepNumber: () => ++stepCounterRef.current
}), [visibleStepCount]);
stepCounterRef.current = 0;
return (
<ToolStepContext.Provider value={contextValue}>
{children}
</ToolStepContext.Provider>
);
}
export default ToolStep;

View File

@@ -0,0 +1,148 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
export interface SplitParameters {
pages: string;
hDiv: string;
vDiv: string;
merge: boolean;
splitType: SplitType | '';
splitValue: string;
bookmarkLevel: string;
includeMetadata: boolean;
allowDuplicates: boolean;
}
export interface SplitSettingsProps {
mode: SplitMode | '';
onModeChange: (mode: SplitMode | '') => void;
parameters: SplitParameters;
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
disabled?: boolean;
}
const SplitSettings = ({
mode,
onModeChange,
parameters,
onParameterChange,
disabled = false
}: SplitSettingsProps) => {
const { t } = useTranslation();
const renderByPagesForm = () => (
<TextInput
label={t("split.splitPages", "Pages")}
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
value={parameters.pages}
onChange={(e) => onParameterChange('pages', e.target.value)}
disabled={disabled}
/>
);
const renderBySectionsForm = () => (
<Stack gap="sm">
<TextInput
label={t("split-by-sections.horizontal.label", "Horizontal Divisions")}
type="number"
min="0"
max="300"
value={parameters.hDiv}
onChange={(e) => onParameterChange('hDiv', e.target.value)}
placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
disabled={disabled}
/>
<TextInput
label={t("split-by-sections.vertical.label", "Vertical Divisions")}
type="number"
min="0"
max="300"
value={parameters.vDiv}
onChange={(e) => onParameterChange('vDiv', e.target.value)}
placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
disabled={disabled}
/>
<Checkbox
label={t("split-by-sections.merge", "Merge sections into one PDF")}
checked={parameters.merge}
onChange={(e) => onParameterChange('merge', e.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
const renderBySizeOrCountForm = () => (
<Stack gap="sm">
<Select
label={t("split-by-size-or-count.type.label", "Split Type")}
value={parameters.splitType}
onChange={(v) => v && onParameterChange('splitType', v)}
disabled={disabled}
data={[
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
{ value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
{ value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") },
]}
/>
<TextInput
label={t("split-by-size-or-count.value.label", "Split Value")}
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
value={parameters.splitValue}
onChange={(e) => onParameterChange('splitValue', e.target.value)}
disabled={disabled}
/>
</Stack>
);
const renderByChaptersForm = () => (
<Stack gap="sm">
<TextInput
label={t("splitByChapters.bookmarkLevel", "Bookmark Level")}
type="number"
value={parameters.bookmarkLevel}
onChange={(e) => onParameterChange('bookmarkLevel', e.target.value)}
disabled={disabled}
/>
<Checkbox
label={t("splitByChapters.includeMetadata", "Include Metadata")}
checked={parameters.includeMetadata}
onChange={(e) => onParameterChange('includeMetadata', e.currentTarget.checked)}
disabled={disabled}
/>
<Checkbox
label={t("splitByChapters.allowDuplicates", "Allow Duplicate Bookmarks")}
checked={parameters.allowDuplicates}
onChange={(e) => onParameterChange('allowDuplicates', e.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
return (
<Stack gap="md">
{/* Mode Selector */}
<Select
label="Choose split method"
placeholder="Select how to split the PDF"
value={mode}
onChange={(v) => v && onModeChange(v)}
disabled={disabled}
data={[
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
{ value: SPLIT_MODES.BY_SECTIONS, label: t("split-by-sections.title", "Split by Grid Sections") },
{ value: SPLIT_MODES.BY_SIZE_OR_COUNT, label: t("split-by-size-or-count.title", "Split by Size or Count") },
{ value: SPLIT_MODES.BY_CHAPTERS, label: t("splitByChapters.title", "Split by Chapters") },
]}
/>
{/* Parameter Form */}
{mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
{mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
{mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
{mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
</Stack>
);
}
export default SplitSettings;