mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-30 20:06:30 +01:00
Merge remote-tracking branch 'origin/V2' into feature/v2/toggle_for_auto_unzip
This commit is contained in:
commit
c42e7b2df6
@ -554,7 +554,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colours/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@ -2798,8 +2798,9 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
@ -3133,6 +3134,7 @@
|
||||
"automate": "Automate",
|
||||
"files": "Files",
|
||||
"activity": "Activity",
|
||||
"account": "Account",
|
||||
"config": "Config",
|
||||
"allTools": "All Tools"
|
||||
},
|
||||
|
||||
@ -562,7 +562,7 @@
|
||||
"adjustContrast": {
|
||||
"tags": "contrast,brightness,saturation",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
|
||||
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
|
||||
},
|
||||
"crop": {
|
||||
"tags": "trim,cut,resize",
|
||||
@ -1712,8 +1712,9 @@
|
||||
"submit": "Sanitize PDF"
|
||||
},
|
||||
"adjustContrast": {
|
||||
"title": "Adjust Contrast",
|
||||
"header": "Adjust Contrast",
|
||||
"title": "Adjust Colors/Contrast",
|
||||
"header": "Adjust Colors/Contrast",
|
||||
"basic": "Basic Adjustments",
|
||||
"contrast": "Contrast:",
|
||||
"brightness": "Brightness:",
|
||||
"saturation": "Saturation:",
|
||||
|
||||
@ -15,7 +15,7 @@ interface AddFileCardProps {
|
||||
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept = "*/*",
|
||||
accept,
|
||||
multiple = true
|
||||
}: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -5,6 +5,8 @@ import { useNavigationGuard } from "../../contexts/NavigationContext";
|
||||
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import { documentManipulationService } from "../../services/documentManipulationService";
|
||||
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
|
||||
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
import './PageEditor.module.css';
|
||||
import PageThumbnail from './PageThumbnail';
|
||||
@ -524,66 +526,38 @@ const PageEditor = ({
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// Step 2: Check if we have multiple documents (splits) or single document
|
||||
if (Array.isArray(processedDocuments)) {
|
||||
// Multiple documents (splits) - export as ZIP
|
||||
const blobs: Blob[] = [];
|
||||
const filenames: string[] = [];
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const baseExportFilename = getExportFilename();
|
||||
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
const partFilename = `${baseName}_part_${i + 1}.pdf`;
|
||||
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
|
||||
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
|
||||
blobs.push(result.blob);
|
||||
filenames.push(result.filename);
|
||||
}
|
||||
|
||||
// Create ZIP file
|
||||
// Step 3: Download
|
||||
if (files.length > 1) {
|
||||
// Multiple files - create ZIP
|
||||
const JSZip = await import('jszip');
|
||||
const zip = new JSZip.default();
|
||||
|
||||
blobs.forEach((blob, index) => {
|
||||
zip.file(filenames[index], blob);
|
||||
files.forEach((file) => {
|
||||
zip.file(file.name, file);
|
||||
});
|
||||
|
||||
const zipBlob = await zip.generateAsync({ type: 'blob' });
|
||||
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
|
||||
const exportFilename = getExportFilename();
|
||||
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
|
||||
|
||||
pdfExportService.downloadFile(zipBlob, zipFilename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
} else {
|
||||
// Single document - regular export
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(
|
||||
processedDocuments,
|
||||
sourceFiles,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
)
|
||||
: await pdfExportService.exportPDF(
|
||||
processedDocuments,
|
||||
[],
|
||||
{ selectedOnly: false, filename: exportFilename }
|
||||
);
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
|
||||
// Single file - download directly
|
||||
const file = files[0];
|
||||
pdfExportService.downloadFile(file, file.name);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
@ -592,21 +566,39 @@ const PageEditor = ({
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
|
||||
|
||||
// Apply DOM changes to document state using dedicated service
|
||||
const applyChanges = useCallback(() => {
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument, // Original order
|
||||
displayDocument, // Current display order (includes reordering)
|
||||
splitPositions // Position-based splits
|
||||
);
|
||||
setExportLoading(true);
|
||||
try {
|
||||
// Step 1: Apply DOM changes to document state first
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
mergedPdfDocument || displayDocument,
|
||||
displayDocument,
|
||||
splitPositions
|
||||
);
|
||||
|
||||
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
|
||||
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
|
||||
setEditedDocument(documentToSet);
|
||||
// Step 2: Export to files
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
|
||||
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions]);
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setExportLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
@ -793,7 +785,7 @@ const PageEditor = ({
|
||||
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
applyChanges();
|
||||
await applyChanges();
|
||||
}}
|
||||
onExportAndContinue={async () => {
|
||||
await onExportAll();
|
||||
|
||||
@ -375,6 +375,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
data-original-rotation={page.rotation}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
@ -17,32 +17,34 @@ export class RotatePageCommand extends DOMCommand {
|
||||
}
|
||||
|
||||
execute(): void {
|
||||
// Only update DOM for immediate visual feedback
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property to match the animated CSS
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const newRotation = currentRotation + this.degrees;
|
||||
let newRotation = currentRotation + this.degrees;
|
||||
|
||||
newRotation = ((newRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${newRotation}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
// Only update DOM
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img) {
|
||||
// Extract current rotation from transform property
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
|
||||
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
|
||||
const previousRotation = currentRotation - this.degrees;
|
||||
let previousRotation = currentRotation - this.degrees;
|
||||
|
||||
previousRotation = ((previousRotation % 360) + 360) % 360;
|
||||
|
||||
img.style.transform = `rotate(${previousRotation}deg)`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ interface FileUploadButtonProps {
|
||||
const FileUploadButton = ({
|
||||
file,
|
||||
onChange,
|
||||
accept = "*/*",
|
||||
accept,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
variant = "outline",
|
||||
|
||||
@ -41,7 +41,6 @@ const LandingPage = () => {
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
accept={["*/*"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
style={{
|
||||
@ -178,7 +177,6 @@ const LandingPage = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="*/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
@ -8,7 +8,7 @@ interface NavigationWarningModalProps {
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue: _onApplyAndContinue,
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
|
||||
@ -30,6 +30,13 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleApplyAndContinue = async () => {
|
||||
if (onApplyAndContinue) {
|
||||
await onApplyAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
@ -49,26 +56,25 @@ const NavigationWarningModal = ({
|
||||
onClose={handleKeepWorking}
|
||||
title={t("unsavedChangesTitle", "Unsaved Changes")}
|
||||
centered
|
||||
size="lg"
|
||||
size="xl"
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
<Stack gap="xl">
|
||||
<Text size="md">
|
||||
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
|
||||
</Text>
|
||||
|
||||
<Group justify="space-between" gap="xl" mt="xl">
|
||||
<Group gap="xl">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
|
||||
<Group justify="space-between" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
{t("discardChanges", "Discard Changes")}
|
||||
</Button>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="var(--mantine-color-gray-8)"
|
||||
@ -76,9 +82,19 @@ const NavigationWarningModal = ({
|
||||
>
|
||||
{t("keepWorking", "Keep Working")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* TODO:: Add this back in when it works */}
|
||||
{/* {_onApplyAndContinue && (
|
||||
<Group gap="xl">
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
{t("exportAndContinue", "Export & Continue")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onApplyAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
@ -86,14 +102,6 @@ const NavigationWarningModal = ({
|
||||
>
|
||||
{t("applyAndContinue", "Apply & Continue")}
|
||||
</Button>
|
||||
)} */}
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
{t("exportAndContinue", "Export & Continue")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@ -13,6 +13,7 @@ import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@ -25,6 +26,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@ -151,8 +153,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
//},
|
||||
{
|
||||
id: 'config',
|
||||
name: t("quickAccess.config", "Config"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { Slider, Text, Group, NumberInput } from '@mantine/core';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
disabled?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export default function SliderWithInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
min = 0,
|
||||
max = 200,
|
||||
step = 1,
|
||||
}: Props) {
|
||||
return (
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>{label}: {Math.round(value)}%</Text>
|
||||
<Group gap="sm" align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Slider min={min} max={max} step={step} value={value} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={(v) => onChange(Number(v) || 0)}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
|
||||
|
||||
interface Props {
|
||||
parameters: AdjustContrastParameters;
|
||||
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdjustContrastBasicSettings({ parameters, onParameterChange, disabled }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SliderWithInput label={t('adjustContrast.contrast', 'Contrast')} value={parameters.contrast} onChange={(v) => onParameterChange('contrast', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.brightness', 'Brightness')} value={parameters.brightness} onChange={(v) => onParameterChange('brightness', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.saturation', 'Saturation')} value={parameters.saturation} onChange={(v) => onParameterChange('saturation', v as any)} disabled={disabled} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import SliderWithInput from '../../shared/sliderWithInput/SliderWithInput';
|
||||
|
||||
interface Props {
|
||||
parameters: AdjustContrastParameters;
|
||||
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AdjustContrastColorSettings({ parameters, onParameterChange, disabled }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SliderWithInput label={t('adjustContrast.red', 'Red')} value={parameters.red} onChange={(v) => onParameterChange('red', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.green', 'Green')} value={parameters.green} onChange={(v) => onParameterChange('green', v as any)} disabled={disabled} />
|
||||
<SliderWithInput label={t('adjustContrast.blue', 'Blue')} value={parameters.blue} onChange={(v) => onParameterChange('blue', v as any)} disabled={disabled} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
|
||||
import ObscuredOverlay from '../../shared/ObscuredOverlay';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { applyAdjustmentsToCanvas } from './utils';
|
||||
|
||||
interface Props {
|
||||
file: File | null;
|
||||
parameters: AdjustContrastParameters;
|
||||
}
|
||||
|
||||
export default function AdjustContrastPreview({ file, parameters }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [thumb, setThumb] = useState<string | null>(null);
|
||||
const { requestThumbnail } = useThumbnailGeneration();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
if (!file || file.type !== 'application/pdf') { setThumb(null); return; }
|
||||
const id = `${file.name}:${file.size}:${file.lastModified}:page:1`;
|
||||
const tUrl = await requestThumbnail(id, file, 1);
|
||||
if (active) setThumb(tUrl || null);
|
||||
};
|
||||
load();
|
||||
return () => { active = false; };
|
||||
}, [file, requestThumbnail]);
|
||||
|
||||
useEffect(() => {
|
||||
const revoked: string | null = null;
|
||||
const render = async () => {
|
||||
if (!thumb || !canvasRef.current) return;
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.src = thumb;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject();
|
||||
});
|
||||
|
||||
// Draw thumbnail to a source canvas
|
||||
const src = document.createElement('canvas');
|
||||
src.width = img.naturalWidth;
|
||||
src.height = img.naturalHeight;
|
||||
const sctx = src.getContext('2d');
|
||||
if (!sctx) return;
|
||||
sctx.drawImage(img, 0, 0);
|
||||
|
||||
// Apply accurate pixel adjustments
|
||||
const adjusted = applyAdjustmentsToCanvas(src, parameters);
|
||||
|
||||
// Draw adjusted onto display canvas
|
||||
const display = canvasRef.current;
|
||||
display.width = adjusted.width;
|
||||
display.height = adjusted.height;
|
||||
const dctx = display.getContext('2d');
|
||||
if (!dctx) return;
|
||||
dctx.clearRect(0, 0, display.width, display.height);
|
||||
dctx.drawImage(adjusted, 0, 0);
|
||||
};
|
||||
render();
|
||||
return () => {
|
||||
if (revoked) URL.revokeObjectURL(revoked);
|
||||
};
|
||||
}, [thumb, parameters]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-color-muted)' }}>{t('common.preview', 'Preview')}</div>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
|
||||
</div>
|
||||
<ObscuredOverlay
|
||||
obscured={!thumb}
|
||||
overlayMessage={<Text size="sm" c="white" fw={600}>{t('adjustContrast.noPreview', 'Select a PDF to preview')}</Text>}
|
||||
borderRadius={6}
|
||||
>
|
||||
<div ref={containerRef} style={{ aspectRatio: '8.5/11', width: '100%', border: '1px solid var(--border-color)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
{thumb && (
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
|
||||
)}
|
||||
</div>
|
||||
</ObscuredOverlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import AdjustContrastBasicSettings from './AdjustContrastBasicSettings';
|
||||
import AdjustContrastColorSettings from './AdjustContrastColorSettings';
|
||||
|
||||
interface Props {
|
||||
parameters: AdjustContrastParameters;
|
||||
onParameterChange: <K extends keyof AdjustContrastParameters>(key: K, value: AdjustContrastParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Single-step settings used by Automate to configure Adjust Contrast in one panel
|
||||
export default function AdjustContrastSingleStepSettings({ parameters, onParameterChange, disabled }: Props) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<AdjustContrastBasicSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<AdjustContrastColorSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
79
frontend/src/components/tools/adjustContrast/utils.ts
Normal file
79
frontend/src/components/tools/adjustContrast/utils.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { AdjustContrastParameters } from '../../../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
|
||||
export function applyAdjustmentsToCanvas(src: HTMLCanvasElement, params: AdjustContrastParameters): HTMLCanvasElement {
|
||||
const out = document.createElement('canvas');
|
||||
out.width = src.width;
|
||||
out.height = src.height;
|
||||
const ctx = out.getContext('2d');
|
||||
if (!ctx) return src;
|
||||
ctx.drawImage(src, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, out.width, out.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const contrast = params.contrast / 100; // 0..2
|
||||
const brightness = params.brightness / 100; // 0..2
|
||||
const saturation = params.saturation / 100; // 0..2
|
||||
const redMul = params.red / 100; // 0..2
|
||||
const greenMul = params.green / 100; // 0..2
|
||||
const blueMul = params.blue / 100; // 0..2
|
||||
|
||||
const clamp = (v: number) => Math.min(255, Math.max(0, v));
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i] * redMul;
|
||||
let g = data[i + 1] * greenMul;
|
||||
let b = data[i + 2] * blueMul;
|
||||
|
||||
// Contrast (centered at 128)
|
||||
r = clamp((r - 128) * contrast + 128);
|
||||
g = clamp((g - 128) * contrast + 128);
|
||||
b = clamp((b - 128) * contrast + 128);
|
||||
|
||||
// Brightness
|
||||
r = clamp(r * brightness);
|
||||
g = clamp(g * brightness);
|
||||
b = clamp(b * brightness);
|
||||
|
||||
// Saturation via HSL
|
||||
const rn = r / 255, gn = g / 255, bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn);
|
||||
let h = 0, s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case rn: h = (gn - bn) / d + (gn < bn ? 6 : 0); break;
|
||||
case gn: h = (bn - rn) / d + 2; break;
|
||||
default: h = (rn - gn) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
s = Math.min(1, Math.max(0, s * saturation));
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||||
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||
if (t < 1/2) return q;
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
let r2: number, g2: number, b2: number;
|
||||
if (s === 0) { r2 = g2 = b2 = l; }
|
||||
else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r2 = hue2rgb(p, q, h + 1/3);
|
||||
g2 = hue2rgb(p, q, h);
|
||||
b2 = hue2rgb(p, q, h - 1/3);
|
||||
}
|
||||
data[i] = clamp(Math.round(r2 * 255));
|
||||
data[i + 1] = clamp(Math.round(g2 * 255));
|
||||
data[i + 2] = clamp(Math.round(b2 * 255));
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@ -54,6 +54,8 @@ export interface ToolFlowConfig {
|
||||
title?: TitleConfig;
|
||||
files: FilesStepConfig;
|
||||
steps: MiddleStepConfig[];
|
||||
// Optional preview content rendered between steps and the execute button
|
||||
preview?: React.ReactNode;
|
||||
executeButton?: ExecuteButtonConfig;
|
||||
review: ReviewStepConfig;
|
||||
forceStepNumbers?: boolean;
|
||||
@ -90,6 +92,10 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
}, stepConfig.content)
|
||||
)}
|
||||
|
||||
{/* Preview (outside steps, above execute button).
|
||||
Hide when review is visible or when no files are selected. */}
|
||||
{!config.review.isVisible && (config.files.selectedFiles?.length ?? 0) > 0 && config.preview}
|
||||
|
||||
{/* Execute Button */}
|
||||
{config.executeButton && config.executeButton.isVisible !== false && (
|
||||
<OperationButton
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Center, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { useFileState } from "../../contexts/FileContext";
|
||||
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||
import { useViewer } from "../../contexts/ViewerContext";
|
||||
import { LocalEmbedPDF } from './LocalEmbedPDF';
|
||||
import { PdfViewerToolbar } from './PdfViewerToolbar';
|
||||
import { ThumbnailSidebar } from './ThumbnailSidebar';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useNavigationGuard, useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers';
|
||||
import NavigationWarningModal from '../shared/NavigationWarningModal';
|
||||
|
||||
export interface EmbedPdfViewerProps {
|
||||
sidebarsVisible: boolean;
|
||||
@ -29,11 +31,33 @@ const EmbedPdfViewerContent = ({
|
||||
const { colorScheme: _colorScheme } = useMantineColorScheme();
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer();
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const zoomState = getZoomState();
|
||||
const spreadState = getSpreadState();
|
||||
const rotationState = getRotationState();
|
||||
|
||||
// Track initial rotation to detect changes
|
||||
const initialRotationRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (initialRotationRef.current === null && rotationState.rotation !== undefined) {
|
||||
initialRotationRef.current = rotationState.rotation;
|
||||
}
|
||||
}, [rotationState.rotation]);
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIds = activeFiles.map(f => f.fileId);
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
|
||||
// Check if we're in signature mode OR viewer annotation mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
@ -42,13 +66,6 @@ const EmbedPdfViewerContent = ({
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors } = useFileState();
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Determine which file to display
|
||||
const currentFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
@ -134,6 +151,65 @@ const EmbedPdfViewerContent = ({
|
||||
};
|
||||
}, [isViewerHovered]);
|
||||
|
||||
// Register checker for unsaved changes (annotations only for now)
|
||||
useEffect(() => {
|
||||
if (previewFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkForChanges = () => {
|
||||
// Check for annotation changes via history
|
||||
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
|
||||
|
||||
console.log('[Viewer] Checking for unsaved changes:', {
|
||||
hasAnnotationChanges
|
||||
});
|
||||
return hasAnnotationChanges;
|
||||
};
|
||||
|
||||
console.log('[Viewer] Registering unsaved changes checker');
|
||||
registerUnsavedChangesChecker(checkForChanges);
|
||||
|
||||
return () => {
|
||||
console.log('[Viewer] Unregistering unsaved changes checker');
|
||||
unregisterUnsavedChangesChecker();
|
||||
};
|
||||
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
|
||||
|
||||
// Apply changes - save annotations to new file version
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!currentFile || activeFileIds.length === 0) return;
|
||||
|
||||
try {
|
||||
console.log('[Viewer] Applying changes - exporting PDF with annotations');
|
||||
|
||||
// Step 1: Export PDF with annotations using EmbedPDF
|
||||
const arrayBuffer = await exportActions.saveAsCopy();
|
||||
if (!arrayBuffer) {
|
||||
throw new Error('Failed to export PDF');
|
||||
}
|
||||
|
||||
console.log('[Viewer] Exported PDF size:', arrayBuffer.byteLength);
|
||||
|
||||
// Step 2: Convert ArrayBuffer to File
|
||||
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
|
||||
const filename = currentFile.name || 'document.pdf';
|
||||
const file = new File([blob], filename, { type: 'application/pdf' });
|
||||
|
||||
// Step 3: Create StirlingFiles and stubs for version history
|
||||
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
|
||||
if (!parentStub) throw new Error('Parent stub not found');
|
||||
|
||||
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'multiTool');
|
||||
|
||||
// Step 4: Consume files (replace in context)
|
||||
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Apply changes failed:', error);
|
||||
}
|
||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@ -240,6 +316,15 @@ const EmbedPdfViewerContent = ({
|
||||
visible={isThumbnailSidebarVisible}
|
||||
onToggle={toggleThumbnailSidebar}
|
||||
/>
|
||||
|
||||
{/* Navigation Warning Modal */}
|
||||
{!previewFile && (
|
||||
<NavigationWarningModal
|
||||
onApplyAndContinue={async () => {
|
||||
await applyChanges();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@ -28,7 +28,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
useEffect(() => {
|
||||
if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||
|
||||
|
||||
@ -74,6 +74,8 @@ export interface NavigationContextActions {
|
||||
setSelectedTool: (toolId: ToolId | null) => void;
|
||||
setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
registerUnsavedChangesChecker: (checker: () => boolean) => void;
|
||||
unregisterUnsavedChangesChecker: () => void;
|
||||
showNavigationWarning: (show: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => void;
|
||||
confirmNavigation: () => void;
|
||||
@ -106,11 +108,29 @@ export const NavigationProvider: React.FC<{
|
||||
}> = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
const unsavedChangesCheckerRef = React.useRef<(() => boolean) | null>(null);
|
||||
|
||||
const actions: NavigationContextActions = {
|
||||
setWorkbench: useCallback((workbench: WorkbenchType) => {
|
||||
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
|
||||
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
|
||||
// Check for unsaved changes using registered checker or state
|
||||
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||
console.log('[NavigationContext] setWorkbench:', {
|
||||
from: state.workbench,
|
||||
to: workbench,
|
||||
hasChecker: !!unsavedChangesCheckerRef.current,
|
||||
hasUnsavedChanges
|
||||
});
|
||||
|
||||
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
|
||||
const leavingWorkbenchWithChanges =
|
||||
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
|
||||
|
||||
if (leavingWorkbenchWithChanges) {
|
||||
// Update state to reflect unsaved changes so modal knows
|
||||
if (!state.hasUnsavedChanges) {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } });
|
||||
}
|
||||
const performWorkbenchChange = () => {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
};
|
||||
@ -126,8 +146,15 @@ export const NavigationProvider: React.FC<{
|
||||
}, []),
|
||||
|
||||
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
|
||||
// If we're leaving pageEditor workbench and have unsaved changes, request navigation
|
||||
if (state.workbench === 'pageEditor' && workbench !== 'pageEditor' && state.hasUnsavedChanges) {
|
||||
// Check for unsaved changes using registered checker or state
|
||||
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||
|
||||
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
|
||||
const leavingWorkbenchWithChanges =
|
||||
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
|
||||
|
||||
if (leavingWorkbenchWithChanges) {
|
||||
const performWorkbenchChange = () => {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
||||
};
|
||||
@ -142,6 +169,14 @@ export const NavigationProvider: React.FC<{
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []),
|
||||
|
||||
registerUnsavedChangesChecker: useCallback((checker: () => boolean) => {
|
||||
unsavedChangesCheckerRef.current = checker;
|
||||
}, []),
|
||||
|
||||
unregisterUnsavedChangesChecker: useCallback(() => {
|
||||
unsavedChangesCheckerRef.current = null;
|
||||
}, []),
|
||||
|
||||
showNavigationWarning: useCallback((show: boolean) => {
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
|
||||
}, []),
|
||||
@ -254,6 +289,8 @@ export const useNavigationGuard = () => {
|
||||
confirmNavigation: actions.confirmNavigation,
|
||||
cancelNavigation: actions.cancelNavigation,
|
||||
setHasUnsavedChanges: actions.setHasUnsavedChanges,
|
||||
setShowNavigationWarning: actions.showNavigationWarning
|
||||
setShowNavigationWarning: actions.showNavigationWarning,
|
||||
registerUnsavedChangesChecker: actions.registerUnsavedChangesChecker,
|
||||
unregisterUnsavedChangesChecker: actions.unregisterUnsavedChangesChecker
|
||||
};
|
||||
};
|
||||
|
||||
@ -57,13 +57,13 @@ const addFilesMutex = new SimpleMutex();
|
||||
/**
|
||||
* Helper to create ProcessedFile metadata structure
|
||||
*/
|
||||
export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
||||
export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) {
|
||||
return {
|
||||
totalPages: pageCount,
|
||||
pages: Array.from({ length: pageCount }, (_, index) => ({
|
||||
pageNumber: index + 1,
|
||||
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
|
||||
rotation: 0,
|
||||
rotation: pageRotations?.[index] ?? 0,
|
||||
splitBefore: false
|
||||
})),
|
||||
thumbnailUrl: thumbnail,
|
||||
@ -82,8 +82,22 @@ export async function generateProcessedFileMetadata(file: File): Promise<Process
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
return createProcessedFile(result.pageCount, result.thumbnail);
|
||||
// Generate unrotated thumbnails for PageEditor (rotation applied via CSS)
|
||||
const unrotatedResult = await generateThumbnailWithMetadata(file, false);
|
||||
|
||||
// Generate rotated thumbnail for file manager display
|
||||
const rotatedResult = await generateThumbnailWithMetadata(file, true);
|
||||
|
||||
const processedFile = createProcessedFile(
|
||||
unrotatedResult.pageCount,
|
||||
unrotatedResult.thumbnail, // Page thumbnails (unrotated)
|
||||
unrotatedResult.pageRotations
|
||||
);
|
||||
|
||||
// Use rotated thumbnail for file manager
|
||||
processedFile.thumbnailUrl = rotatedResult.thumbnail;
|
||||
|
||||
return processedFile;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
||||
}
|
||||
|
||||
@ -14,6 +14,9 @@ import ReorganizePages from "../tools/ReorganizePages";
|
||||
import { reorganizePagesOperationConfig } from "../hooks/tools/reorganizePages/useReorganizePagesOperation";
|
||||
import RemovePassword from "../tools/RemovePassword";
|
||||
import { SubcategoryId, ToolCategoryId, ToolRegistry } from "./toolsTaxonomy";
|
||||
import AdjustContrast from "../tools/AdjustContrast";
|
||||
import AdjustContrastSingleStepSettings from "../components/tools/adjustContrast/AdjustContrastSingleStepSettings";
|
||||
import { adjustContrastOperationConfig } from "../hooks/tools/adjustContrast/useAdjustContrastOperation";
|
||||
import { getSynonyms } from "../utils/toolSynonyms";
|
||||
import AddWatermark from "../tools/AddWatermark";
|
||||
import AddStamp from "../tools/AddStamp";
|
||||
@ -665,12 +668,13 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
adjustContrast: {
|
||||
icon: <LocalIcon icon="palette" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
|
||||
component: null,
|
||||
component: AdjustContrast,
|
||||
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
operationConfig: adjustContrastOperationConfig,
|
||||
automationSettings: AdjustContrastSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "adjustContrast"),
|
||||
automationSettings: null,
|
||||
},
|
||||
repair: {
|
||||
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { AdjustContrastParameters, defaultParameters } from './useAdjustContrastParameters';
|
||||
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
|
||||
import { applyAdjustmentsToCanvas } from '../../../components/tools/adjustContrast/utils';
|
||||
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
|
||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
|
||||
async function renderPdfPageToCanvas(pdf: any, pageNumber: number, scale: number): Promise<HTMLCanvasElement> {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas 2D context unavailable');
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// adjustment logic moved to shared util
|
||||
|
||||
// Render, adjust, and assemble all pages of a single PDF into a new PDF
|
||||
async function buildAdjustedPdfForFile(file: File, params: AdjustContrastParameters): Promise<File> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {});
|
||||
const pageCount = pdf.numPages;
|
||||
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
for (let p = 1; p <= pageCount; p++) {
|
||||
const srcCanvas = await renderPdfPageToCanvas(pdf, p, 2);
|
||||
const adjusted = applyAdjustmentsToCanvas(srcCanvas, params);
|
||||
const pngUrl = adjusted.toDataURL('image/png');
|
||||
const res = await fetch(pngUrl);
|
||||
const pngBytes = new Uint8Array(await res.arrayBuffer());
|
||||
const embedded = await newDoc.embedPng(pngBytes);
|
||||
const { width, height } = embedded.scale(1);
|
||||
const page = newDoc.addPage([width, height]);
|
||||
page.drawImage(embedded, { x: 0, y: 0, width, height });
|
||||
}
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
const out = createFileFromApiResponse(pdfBytes, { 'content-type': 'application/pdf' }, file.name);
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function processPdfClientSide(params: AdjustContrastParameters, files: File[]): Promise<File[]> {
|
||||
// Limit concurrency to avoid exhausting memory/CPU while still getting speedups
|
||||
// Heuristic: use up to 4 workers on capable machines, otherwise 2-3
|
||||
let CONCURRENCY_LIMIT = 2;
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.hardwareConcurrency === 'number') {
|
||||
if (navigator.hardwareConcurrency >= 8) CONCURRENCY_LIMIT = 4;
|
||||
else if (navigator.hardwareConcurrency >= 4) CONCURRENCY_LIMIT = 3;
|
||||
}
|
||||
CONCURRENCY_LIMIT = Math.min(CONCURRENCY_LIMIT, files.length);
|
||||
|
||||
const mapWithConcurrency = async <T, R>(items: T[], limit: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]> => {
|
||||
const results: R[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
|
||||
const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
|
||||
let current = nextIndex++;
|
||||
while (current < items.length) {
|
||||
results[current] = await worker(items[current], current);
|
||||
current = nextIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
};
|
||||
|
||||
return mapWithConcurrency(files, CONCURRENCY_LIMIT, (file) => buildAdjustedPdfForFile(file, params));
|
||||
}
|
||||
|
||||
export const adjustContrastOperationConfig = {
|
||||
toolType: ToolType.custom,
|
||||
customProcessor: processPdfClientSide,
|
||||
operationType: 'adjustContrast',
|
||||
defaultParameters,
|
||||
// Single-step settings component for Automate
|
||||
settingsComponentPath: 'components/tools/adjustContrast/AdjustContrastSingleStepSettings',
|
||||
} as const;
|
||||
|
||||
export const useAdjustContrastOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
return useToolOperation<AdjustContrastParameters>({
|
||||
...adjustContrastOperationConfig,
|
||||
getErrorMessage: () => t('adjustContrast.error.failed', 'Failed to adjust colors/contrast')
|
||||
});
|
||||
};
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface AdjustContrastParameters {
|
||||
contrast: number; // 0-200 (%), 100 = neutral
|
||||
brightness: number; // 0-200 (%), 100 = neutral
|
||||
saturation: number; // 0-200 (%), 100 = neutral
|
||||
red: number; // 0-200 (%), 100 = neutral
|
||||
green: number; // 0-200 (%), 100 = neutral
|
||||
blue: number; // 0-200 (%), 100 = neutral
|
||||
}
|
||||
|
||||
export const defaultParameters: AdjustContrastParameters = {
|
||||
contrast: 100,
|
||||
brightness: 100,
|
||||
saturation: 100,
|
||||
red: 100,
|
||||
green: 100,
|
||||
blue: 100,
|
||||
};
|
||||
|
||||
export type AdjustContrastParametersHook = BaseParametersHook<AdjustContrastParameters>;
|
||||
|
||||
export const useAdjustContrastParameters = (): AdjustContrastParametersHook => {
|
||||
return useBaseParameters<AdjustContrastParameters>({
|
||||
defaultParameters,
|
||||
endpointName: '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -10,14 +10,8 @@ export class DocumentManipulationService {
|
||||
* Returns single document or multiple documents if splits are present
|
||||
*/
|
||||
applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set<number>): PDFDocument | PDFDocument[] {
|
||||
console.log('DocumentManipulationService: Applying DOM changes to document');
|
||||
console.log('Original document page order:', pdfDocument.pages.map(p => p.pageNumber));
|
||||
console.log('Current display order:', currentDisplayOrder?.pages.map(p => p.pageNumber) || 'none provided');
|
||||
console.log('Split positions:', splitPositions ? Array.from(splitPositions).sort() : 'none');
|
||||
|
||||
// Use current display order (from React state) if provided, otherwise use original order
|
||||
const baseDocument = currentDisplayOrder || pdfDocument;
|
||||
console.log('Using page order:', baseDocument.pages.map(p => p.pageNumber));
|
||||
|
||||
// Apply DOM changes to each page (rotation only now, splits are position-based)
|
||||
let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page));
|
||||
@ -57,32 +51,25 @@ export class DocumentManipulationService {
|
||||
private createSplitDocuments(document: PDFDocument): PDFDocument[] {
|
||||
const documents: PDFDocument[] = [];
|
||||
const splitPoints: number[] = [];
|
||||
|
||||
|
||||
// Find split points
|
||||
document.pages.forEach((page, index) => {
|
||||
if (page.splitAfter) {
|
||||
console.log(`Found split marker at page ${page.pageNumber} (index ${index}), adding split point at ${index + 1}`);
|
||||
splitPoints.push(index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Add end point if not already there
|
||||
if (splitPoints.length === 0 || splitPoints[splitPoints.length - 1] !== document.pages.length) {
|
||||
splitPoints.push(document.pages.length);
|
||||
}
|
||||
|
||||
console.log('Final split points:', splitPoints);
|
||||
console.log('Total pages to split:', document.pages.length);
|
||||
|
||||
|
||||
let startIndex = 0;
|
||||
let partNumber = 1;
|
||||
|
||||
|
||||
for (const endIndex of splitPoints) {
|
||||
const segmentPages = document.pages.slice(startIndex, endIndex);
|
||||
|
||||
console.log(`Creating split document ${partNumber}: pages ${startIndex}-${endIndex-1} (${segmentPages.length} pages)`);
|
||||
console.log(`Split document ${partNumber} page numbers:`, segmentPages.map(p => p.pageNumber));
|
||||
|
||||
|
||||
if (segmentPages.length > 0) {
|
||||
documents.push({
|
||||
...document,
|
||||
@ -93,11 +80,10 @@ export class DocumentManipulationService {
|
||||
});
|
||||
partNumber++;
|
||||
}
|
||||
|
||||
|
||||
startIndex = endIndex;
|
||||
}
|
||||
|
||||
console.log(`Created ${documents.length} split documents`);
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
@ -108,7 +94,6 @@ export class DocumentManipulationService {
|
||||
// Find the DOM element for this page
|
||||
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
|
||||
if (!pageElement) {
|
||||
console.log(`Page ${page.pageNumber}: No DOM element found, keeping original state`);
|
||||
return page;
|
||||
}
|
||||
|
||||
@ -116,8 +101,7 @@ export class DocumentManipulationService {
|
||||
|
||||
// Apply rotation changes from DOM
|
||||
updatedPage.rotation = this.getRotationFromDOM(pageElement, page);
|
||||
|
||||
|
||||
|
||||
return updatedPage;
|
||||
}
|
||||
|
||||
@ -126,16 +110,21 @@ export class DocumentManipulationService {
|
||||
*/
|
||||
private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number {
|
||||
const img = pageElement.querySelector('img');
|
||||
if (img && img.style.transform) {
|
||||
// Parse rotation from transform property (e.g., "rotate(90deg)" -> 90)
|
||||
const rotationMatch = img.style.transform.match(/rotate\((-?\d+)deg\)/);
|
||||
const domRotation = rotationMatch ? parseInt(rotationMatch[1]) : 0;
|
||||
|
||||
console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`);
|
||||
return domRotation;
|
||||
if (img) {
|
||||
const originalRotation = parseInt(img.getAttribute('data-original-rotation') || '0');
|
||||
|
||||
const currentTransform = img.style.transform || '';
|
||||
const rotationMatch = currentTransform.match(/rotate\((-?\d+)deg\)/);
|
||||
const visualRotation = rotationMatch ? parseInt(rotationMatch[1]) : originalRotation;
|
||||
|
||||
const userChange = ((visualRotation - originalRotation) % 360 + 360) % 360;
|
||||
|
||||
let finalRotation = (originalPage.rotation + userChange) % 360;
|
||||
if (finalRotation === 360) finalRotation = 0;
|
||||
|
||||
return finalRotation;
|
||||
}
|
||||
|
||||
console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`);
|
||||
|
||||
return originalPage.rotation;
|
||||
}
|
||||
|
||||
|
||||
@ -200,11 +200,13 @@ export class EnhancedPDFProcessingService {
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
|
||||
@ -254,7 +256,7 @@ export class EnhancedPDFProcessingService {
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
rotation: page.rotate || 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
@ -265,11 +267,15 @@ export class EnhancedPDFProcessingService {
|
||||
|
||||
// Create placeholder pages for remaining pages
|
||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||
// Load page just to get rotation
|
||||
const page = await pdf.getPage(i);
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
rotation: 0,
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
@ -316,7 +322,7 @@ export class EnhancedPDFProcessingService {
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
rotation: page.rotate || 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
@ -333,11 +339,15 @@ export class EnhancedPDFProcessingService {
|
||||
|
||||
// Create placeholders for remaining pages
|
||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||
// Load page just to get rotation
|
||||
const page = await pdf.getPage(i);
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
@ -367,11 +377,15 @@ export class EnhancedPDFProcessingService {
|
||||
// Create placeholder pages without thumbnails
|
||||
const pages: PDFPage[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
// Load page just to get rotation
|
||||
const page = await pdf.getPage(i);
|
||||
const rotation = page.rotate || 0;
|
||||
|
||||
pages.push({
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
rotation,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
@ -390,7 +404,7 @@ export class EnhancedPDFProcessingService {
|
||||
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
|
||||
const scale = scales[quality];
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
const viewport = page.getViewport({ scale, rotation: 0 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
34
frontend/src/services/fileStubHelpers.ts
Normal file
34
frontend/src/services/fileStubHelpers.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { StirlingFile, StirlingFileStub } from '../types/fileContext';
|
||||
import { createChildStub, generateProcessedFileMetadata } from '../contexts/file/fileActions';
|
||||
import { createStirlingFile } from '../types/fileContext';
|
||||
import { ToolId } from '../types/toolId';
|
||||
|
||||
/**
|
||||
* Create StirlingFiles and StirlingFileStubs from exported files
|
||||
* Used when saving page editor changes to create version history
|
||||
*/
|
||||
export async function createStirlingFilesAndStubs(
|
||||
files: File[],
|
||||
parentStub: StirlingFileStub,
|
||||
toolId: ToolId
|
||||
): Promise<{ stirlingFiles: StirlingFile[], stubs: StirlingFileStub[] }> {
|
||||
const stirlingFiles: StirlingFile[] = [];
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(file);
|
||||
const childStub = createChildStub(
|
||||
parentStub,
|
||||
{ toolId, timestamp: Date.now() },
|
||||
file,
|
||||
processedFileMetadata?.thumbnailUrl,
|
||||
processedFileMetadata
|
||||
);
|
||||
|
||||
const stirlingFile = createStirlingFile(file, childStub.id);
|
||||
stirlingFiles.push(stirlingFile);
|
||||
stubs.push(childStub);
|
||||
}
|
||||
|
||||
return { stirlingFiles, stubs };
|
||||
}
|
||||
46
frontend/src/services/pdfExportHelpers.ts
Normal file
46
frontend/src/services/pdfExportHelpers.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { PDFDocument } from '../types/pageEditor';
|
||||
import { pdfExportService } from './pdfExportService';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
/**
|
||||
* Export processed documents to File objects
|
||||
* Handles both single documents and split documents (multiple PDFs)
|
||||
*/
|
||||
export async function exportProcessedDocumentsToFiles(
|
||||
processedDocuments: PDFDocument | PDFDocument[],
|
||||
sourceFiles: Map<FileId, File> | null,
|
||||
exportFilename: string
|
||||
): Promise<File[]> {
|
||||
console.log('exportProcessedDocumentsToFiles called with:', {
|
||||
isArray: Array.isArray(processedDocuments),
|
||||
numDocs: Array.isArray(processedDocuments) ? processedDocuments.length : 1,
|
||||
hasSourceFiles: sourceFiles !== null,
|
||||
sourceFilesSize: sourceFiles?.size
|
||||
});
|
||||
|
||||
if (Array.isArray(processedDocuments)) {
|
||||
// Multiple documents (splits)
|
||||
const files: File[] = [];
|
||||
const baseName = exportFilename.replace(/\.pdf$/i, '');
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
const partFilename = `${baseName}_part_${i + 1}.pdf`;
|
||||
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { selectedOnly: false, filename: partFilename })
|
||||
: await pdfExportService.exportPDF(doc, [], { selectedOnly: false, filename: partFilename });
|
||||
|
||||
files.push(new File([result.blob], result.filename, { type: 'application/pdf' }));
|
||||
}
|
||||
|
||||
return files;
|
||||
} else {
|
||||
// Single document
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(processedDocuments, sourceFiles, [], { selectedOnly: false, filename: exportFilename })
|
||||
: await pdfExportService.exportPDF(processedDocuments, [], { selectedOnly: false, filename: exportFilename });
|
||||
|
||||
return [new File([result.blob], result.filename, { type: 'application/pdf' })];
|
||||
}
|
||||
}
|
||||
@ -98,10 +98,7 @@ export class PDFExportService {
|
||||
// Create a blank page
|
||||
const blankPage = newDoc.addPage(PageSizes.A4);
|
||||
|
||||
// Apply rotation if needed
|
||||
if (page.rotation !== 0) {
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
} else if (page.originalFileId && loadedDocs.has(page.originalFileId)) {
|
||||
// Get the correct source document for this page
|
||||
const sourceDoc = loadedDocs.get(page.originalFileId)!;
|
||||
@ -111,10 +108,7 @@ export class PDFExportService {
|
||||
// Copy the page from the correct source document
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
// Apply rotation
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
@ -147,10 +141,7 @@ export class PDFExportService {
|
||||
// Create a blank page
|
||||
const blankPage = newDoc.addPage(PageSizes.A4);
|
||||
|
||||
// Apply rotation if needed
|
||||
if (page.rotation !== 0) {
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
blankPage.setRotation(degrees(page.rotation));
|
||||
} else {
|
||||
// Get the original page from source document using originalPageNumber
|
||||
const sourcePageIndex = page.originalPageNumber - 1;
|
||||
@ -159,10 +150,7 @@ export class PDFExportService {
|
||||
// Copy the page
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
// Apply rotation
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
|
||||
@ -164,7 +164,7 @@ export class ThumbnailGenerationService {
|
||||
for (const pageNumber of batch) {
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const viewport = page.getViewport({ scale, rotation: 0 });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
|
||||
121
frontend/src/tools/AdjustContrast.tsx
Normal file
121
frontend/src/tools/AdjustContrast.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createToolFlow } from '../components/tools/shared/createToolFlow';
|
||||
import { BaseToolProps, ToolComponent } from '../types/tool';
|
||||
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
|
||||
import { useAdjustContrastParameters } from '../hooks/tools/adjustContrast/useAdjustContrastParameters';
|
||||
import { useAdjustContrastOperation } from '../hooks/tools/adjustContrast/useAdjustContrastOperation';
|
||||
import AdjustContrastBasicSettings from '../components/tools/adjustContrast/AdjustContrastBasicSettings';
|
||||
import AdjustContrastColorSettings from '../components/tools/adjustContrast/AdjustContrastColorSettings';
|
||||
import AdjustContrastPreview from '../components/tools/adjustContrast/AdjustContrastPreview';
|
||||
import { useAccordionSteps } from '../hooks/tools/shared/useAccordionSteps';
|
||||
import NavigationArrows from '../components/shared/filePreview/NavigationArrows';
|
||||
|
||||
const AdjustContrast = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
'adjustContrast',
|
||||
useAdjustContrastParameters,
|
||||
useAdjustContrastOperation,
|
||||
props
|
||||
);
|
||||
|
||||
enum Step { NONE='none', BASIC='basic', COLORS='colors' }
|
||||
const accordion = useAccordionSteps<Step>({
|
||||
noneValue: Step.NONE,
|
||||
initialStep: Step.BASIC,
|
||||
stateConditions: { hasFiles: base.hasFiles, hasResults: base.hasResults },
|
||||
afterResults: base.handleSettingsReset
|
||||
});
|
||||
|
||||
// Track which selected file is being previewed. Clamp when selection changes.
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const totalSelected = base.selectedFiles.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (previewIndex >= totalSelected) {
|
||||
setPreviewIndex(Math.max(0, totalSelected - 1));
|
||||
}
|
||||
}, [totalSelected, previewIndex]);
|
||||
|
||||
const currentFile = useMemo(() => {
|
||||
return totalSelected > 0 ? base.selectedFiles[previewIndex] : null;
|
||||
}, [base.selectedFiles, previewIndex, totalSelected]);
|
||||
|
||||
const handlePrev = () => setPreviewIndex((i) => Math.max(0, i - 1));
|
||||
const handleNext = () => setPreviewIndex((i) => Math.min(totalSelected - 1, i + 1));
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t('adjustContrast.basic', 'Basic Adjustments'),
|
||||
isCollapsed: accordion.getCollapsedState(Step.BASIC),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(Step.BASIC),
|
||||
content: (
|
||||
<AdjustContrastBasicSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('adjustContrast.adjustColors', 'Adjust Colors'),
|
||||
isCollapsed: accordion.getCollapsedState(Step.COLORS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(Step.COLORS),
|
||||
content: (
|
||||
<AdjustContrastColorSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
preview: (
|
||||
<div>
|
||||
<NavigationArrows
|
||||
onPrevious={handlePrev}
|
||||
onNext={handleNext}
|
||||
disabled={totalSelected <= 1}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<AdjustContrastPreview
|
||||
file={currentFile || null}
|
||||
parameters={base.params.parameters}
|
||||
/>
|
||||
</div>
|
||||
</NavigationArrows>
|
||||
{totalSelected > 1 && (
|
||||
<div style={{ textAlign: 'center', marginTop: 8, fontSize: 12, color: 'var(--text-color-muted)' }}>
|
||||
{`${previewIndex + 1} of ${totalSelected}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
executeButton: {
|
||||
text: t('adjustContrast.confirm', 'Confirm'),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t('loading'),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.hasFiles,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t('adjustContrast.results.title', 'Adjusted PDF'),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
forceStepNumbers: true,
|
||||
});
|
||||
};
|
||||
|
||||
export default AdjustContrast as ToolComponent;
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
export interface ThumbnailWithMetadata {
|
||||
thumbnail: string; // Always returns a thumbnail (placeholder if needed)
|
||||
pageCount: number;
|
||||
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
|
||||
}
|
||||
|
||||
interface ColorScheme {
|
||||
@ -377,8 +378,10 @@ export async function generateThumbnailForFile(file: File): Promise<string> {
|
||||
|
||||
/**
|
||||
* Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail
|
||||
* @param applyRotation - If true, render thumbnail with PDF rotation applied (for static display).
|
||||
* If false, render without rotation (for CSS-based rotation in PageEditor)
|
||||
*/
|
||||
export async function generateThumbnailWithMetadata(file: File): Promise<ThumbnailWithMetadata> {
|
||||
export async function generateThumbnailWithMetadata(file: File, applyRotation: boolean = true): Promise<ThumbnailWithMetadata> {
|
||||
// Non-PDF files have no page count
|
||||
if (!file.type.startsWith('application/pdf')) {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
@ -399,7 +402,13 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
// If applyRotation is false, render without rotation (for CSS-based rotation)
|
||||
// If applyRotation is true, let PDF.js apply rotation (for static display)
|
||||
const viewport = applyRotation
|
||||
? page.getViewport({ scale })
|
||||
: page.getViewport({ scale, rotation: 0 });
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
@ -413,8 +422,16 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
|
||||
await page.render({ canvasContext: context, viewport, canvas }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
// Read rotation for all pages
|
||||
const pageRotations: number[] = [];
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
const p = await pdf.getPage(i);
|
||||
const rotation = p.rotate || 0;
|
||||
pageRotations.push(rotation);
|
||||
}
|
||||
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
return { thumbnail, pageCount };
|
||||
return { thumbnail, pageCount, pageRotations };
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "PasswordException") {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user