Merge branch 'V2' into V2-merge

# Conflicts:
#	frontend/src/components/tools/shared/FileStatusIndicator.tsx
This commit is contained in:
James Brunton 2025-08-27 09:42:54 +01:00
commit 8b16257371
69 changed files with 4253 additions and 2635 deletions

View File

@ -1,4 +1,4 @@
<svg width="146" height="157" viewBox="0 0 146 157" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#E6E6E6" fill-opacity="0.4"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#E6E6E6" fill-opacity="0.7"/>
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#E6E6E6" fill-opacity="0.4"/>
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#E6E6E6" fill-opacity="0.7"/>
</svg>

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 253 B

View File

@ -1,4 +1,4 @@
<svg width="146" height="146" viewBox="0 0 146 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#ACACAC" fill-opacity="0.3"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#FC9999" fill-opacity="0.5"/>
<svg width="92" height="100" viewBox="0 0 92 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 50L60 0V46.5L0 96.5V50Z" fill="#ACACAC" fill-opacity="0.3"/>
<path d="M32 53L92 3V49.5L32 99.5V53Z" fill="#FC9999" fill-opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 366 B

After

Width:  |  Height:  |  Size: 253 B

View File

@ -51,7 +51,6 @@
"placeholder": "Select a PDF file in the main view to get started",
"upload": "Upload",
"addFiles": "Add files",
"noFiles": "No files uploaded. ",
"selectFromWorkbench": "Select files from the workbench or "
},
"noFavourites": "No favourites added",
@ -2137,7 +2136,7 @@
"selectedCount": "{{count}} selected",
"download": "Download",
"delete": "Delete",
"unsupported":"Unsupported"
"unsupported": "Unsupported"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
@ -2309,78 +2308,85 @@
}
},
"automate": {
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.",
"invalidStep": "Invalid step",
"files": {
"placeholder": "Select files to process with this automation"
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.",
"invalidStep": "Invalid step",
"files": {
"placeholder": "Select files to process with this automation"
},
"selection": {
"title": "Automation Selection",
"saved": {
"title": "Saved"
},
"selection": {
"title": "Automation Selection",
"saved": {
"title": "Saved"
},
"createNew": {
"title": "Create New Automation"
},
"suggested": {
"title": "Suggested"
}
"createNew": {
"title": "Create New Automation"
},
"creation": {
"createTitle": "Create Automation",
"editTitle": "Edit Automation",
"description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.",
"name": {
"placeholder": "Automation name"
},
"tools": {
"selectTool": "Select a tool...",
"selected": "Selected Tools",
"remove": "Remove tool",
"configure": "Configure tool",
"notConfigured": "! Not Configured",
"addTool": "Add Tool",
"add": "Add a tool..."
},
"save": "Save Automation",
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.",
"cancel": "Cancel",
"confirm": "Go Back"
}
"suggested": {
"title": "Suggested"
}
},
"creation": {
"createTitle": "Create Automation",
"editTitle": "Edit Automation",
"intro": "Automations run tools sequentially. To get started, add tools in the order you want them to run.",
"name": {
"label": "Automation Name",
"placeholder": "My Automation"
},
"run": {
"title": "Run Automation"
"description": {
"label": "Description (optional)",
"placeholder": "Describe what this automation does..."
},
"sequence": {
"unnamed": "Unnamed Automation",
"steps": "{{count}} steps",
"running": "Running Automation...",
"run": "Run Automation",
"finish": "Finish"
"tools": {
"selectTool": "Select a tool...",
"selected": "Selected Tools",
"remove": "Remove tool",
"configure": "Configure tool",
"notConfigured": "! Not Configured",
"addTool": "Add Tool",
"add": "Add a tool..."
},
"reviewTitle": "Automation Results",
"config": {
"loading": "Loading tool configuration...",
"noSettings": "This tool does not have configurable settings.",
"title": "Configure {{toolName}}",
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
"save": "Save Automation",
"unsavedChanges": {
"title": "Unsaved Changes",
"message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.",
"cancel": "Cancel",
"save": "Save Configuration"
"confirm": "Go Back"
},
"copyToSaved": "Copy to Saved"
}
"icon": {
"label": "Icon"
}
},
"run": {
"title": "Run Automation"
},
"sequence": {
"unnamed": "Unnamed Automation",
"steps": "{{count}} steps",
"running": "Running Automation...",
"run": "Run Automation",
"finish": "Finish"
},
"reviewTitle": "Automation Results",
"config": {
"loading": "Loading tool configuration...",
"noSettings": "This tool does not have configurable settings.",
"title": "Configure {{toolName}}",
"description": "Configure the settings for this tool. These settings will be applied when the automation runs.",
"cancel": "Cancel",
"save": "Save Configuration"
},
"copyToSaved": "Copy to Saved"
},
"automation": {
"suggested": {
"securePdfIngestion": "Secure PDF Ingestion",
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitises documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimises file size.",
"emailPreparation": "Email Preparation",
"emailPreparation": "Email Preparation",
"emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
"secureWorkflow": "Security Workflow",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access.",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.",
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}

View File

@ -2111,10 +2111,10 @@
"suggested": {
"securePdfIngestion": "Secure PDF Ingestion",
"securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.",
"emailPreparation": "Email Preparation",
"emailPreparation": "Email Preparation",
"emailPreparationDesc": "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.",
"secureWorkflow": "Security Workflow",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access.",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default.",
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}

View File

@ -1,7 +1,6 @@
#!/usr/bin/env node
const { icons } = require('@iconify-json/material-symbols');
const { getIcons } = require('@iconify/utils');
const fs = require('fs');
const path = require('path');
@ -89,68 +88,73 @@ function scanForUsedIcons() {
return iconArray;
}
// Auto-detect used icons
const usedIcons = scanForUsedIcons();
// Main async function
async function main() {
// Auto-detect used icons
const usedIcons = scanForUsedIcons();
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
let needsRegeneration = true;
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json');
let needsRegeneration = true;
if (fs.existsSync(outputPath)) {
try {
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
if (fs.existsSync(outputPath)) {
try {
const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
const existingIcons = Object.keys(existingSet.icons || {}).sort();
const currentIcons = [...usedIcons].sort();
if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) {
needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
}
} catch (error) {
// If we can't parse existing file, regenerate
needsRegeneration = true;
}
} catch (error) {
// If we can't parse existing file, regenerate
needsRegeneration = true;
}
}
if (!needsRegeneration) {
info('🎉 No regeneration needed!');
process.exit(0);
}
if (!needsRegeneration) {
info('🎉 No regeneration needed!');
process.exit(0);
}
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`);
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
// Dynamic import of ES module
const { getIcons } = await import('@iconify/utils');
// Extract only our used icons from the full set
const extractedIcons = getIcons(icons, usedIcons);
if (!extractedIcons) {
console.error('❌ Failed to extract icons');
process.exit(1);
}
if (!extractedIcons) {
console.error('❌ Failed to extract icons');
process.exit(1);
}
// Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.icons || {});
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
// Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.icons || {});
const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon));
if (missingIcons.length > 0) {
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
}
if (missingIcons.length > 0) {
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`);
info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.');
}
// Create output directory
const outputDir = path.join(__dirname, '..', 'src', 'assets');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Create output directory
const outputDir = path.join(__dirname, '..', 'src', 'assets');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write the extracted icon set to a file (outputPath already defined above)
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
// Write the extracted icon set to a file (outputPath already defined above)
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
info(`💾 Saved to: ${outputPath}`);
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
info(`💾 Saved to: ${outputPath}`);
// Generate TypeScript types
const typesContent = `// Auto-generated icon types
// Generate TypeScript types
const typesContent = `// Auto-generated icon types
// This file is automatically generated by scripts/generate-icons.js
// Do not edit manually - changes will be overwritten
@ -168,8 +172,15 @@ declare const iconSet: IconSet;
export default iconSet;
`;
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
fs.writeFileSync(typesPath, typesContent);
const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts');
fs.writeFileSync(typesPath, typesContent);
info(`📝 Generated types: ${typesPath}`);
info(`🎉 Icon extraction complete!`);
info(`📝 Generated types: ${typesPath}`);
info(`🎉 Icon extraction complete!`);
}
// Run the main function
main().catch(error => {
console.error('❌ Script failed:', error);
process.exit(1);
});

View File

@ -1,335 +0,0 @@
import { Command, CommandSequence } from '../hooks/useUndoRedo';
import { PDFDocument, PDFPage } from '../types/pageEditor';
// Base class for page operations
abstract class PageCommand implements Command {
protected pdfDocument: PDFDocument;
protected setPdfDocument: (doc: PDFDocument) => void;
protected previousState: PDFDocument;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void
) {
this.pdfDocument = pdfDocument;
this.setPdfDocument = setPdfDocument;
this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone
}
abstract execute(): void;
abstract description: string;
undo(): void {
this.setPdfDocument(this.previousState);
}
}
// Rotate pages command
export class RotatePagesCommand extends PageCommand {
private pageIds: string[];
private rotation: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
rotation: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.rotation = rotation;
}
execute(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, rotation: page.rotation + this.rotation };
}
return page;
});
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
const direction = this.rotation > 0 ? 'right' : 'left';
return `Rotate ${this.pageIds.length} page(s) ${direction}`;
}
}
// Delete pages command
export class DeletePagesCommand extends PageCommand {
private pageIds: string[];
private deletedPages: PDFPage[];
private deletedPositions: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.deletedPages = [];
this.deletedPositions = new Map();
}
execute(): void {
// Store deleted pages and their positions for undo
this.deletedPages = this.pdfDocument.pages.filter(page =>
this.pageIds.includes(page.id)
);
this.deletedPages.forEach(page => {
const index = this.pdfDocument.pages.findIndex(p => p.id === page.id);
this.deletedPositions.set(page.id, index);
});
const updatedPages = this.pdfDocument.pages
.filter(page => !this.pageIds.includes(page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
// Simply restore to the previous state (before deletion)
this.setPdfDocument(this.previousState);
}
get description(): string {
return `Delete ${this.pageIds.length} page(s)`;
}
}
// Move pages command
export class MovePagesCommand extends PageCommand {
private pageIds: string[];
private targetIndex: number;
private originalIndices: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.targetIndex = targetIndex;
this.originalIndices = new Map();
}
execute(): void {
// Store original positions
this.pageIds.forEach(pageId => {
const index = this.pdfDocument.pages.findIndex(p => p.id === pageId);
this.originalIndices.set(pageId, index);
});
let newPages = [...this.pdfDocument.pages];
const pagesToMove = this.pageIds
.map(id => this.pdfDocument.pages.find(p => p.id === id))
.filter((page): page is PDFPage => page !== undefined);
// Remove pages to move
newPages = newPages.filter(page => !this.pageIds.includes(page.id));
// Insert pages at target position
newPages.splice(this.targetIndex, 0, ...pagesToMove);
// Update page numbers
newPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: newPages,
totalPages: newPages.length
});
}
get description(): string {
return `Move ${this.pageIds.length} page(s)`;
}
}
// Reorder single page command (for drag-and-drop)
export class ReorderPageCommand extends PageCommand {
private pageId: string;
private targetIndex: number;
private originalIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageId: string,
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageId = pageId;
this.targetIndex = targetIndex;
this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
}
execute(): void {
const newPages = [...this.pdfDocument.pages];
const [movedPage] = newPages.splice(this.originalIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
// Update page numbers
const updatedPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`;
}
}
// Toggle split markers command
export class ToggleSplitCommand extends PageCommand {
private pageIds: string[];
private previousSplitStates: Map<string, boolean>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.previousSplitStates = new Map();
}
execute(): void {
// Store previous split states
this.pageIds.forEach(pageId => {
const page = this.pdfDocument.pages.find(p => p.id === pageId);
if (page) {
this.previousSplitStates.set(pageId, !!page.splitBefore);
}
});
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, splitBefore: !page.splitBefore };
}
return page;
});
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
const previousState = this.previousSplitStates.get(page.id);
return { ...page, splitBefore: previousState };
}
return page;
});
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Toggle split markers for ${this.pageIds.length} page(s)`;
}
}
// Add pages command (for inserting new files)
export class AddPagesCommand extends PageCommand {
private newPages: PDFPage[];
private insertIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
newPages: PDFPage[],
insertIndex: number = -1 // -1 means append to end
) {
super(pdfDocument, setPdfDocument);
this.newPages = newPages;
this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex;
}
execute(): void {
const newPagesArray = [...this.pdfDocument.pages];
newPagesArray.splice(this.insertIndex, 0, ...this.newPages);
// Update page numbers for all pages
const updatedPages = newPagesArray.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
const updatedPages = this.pdfDocument.pages
.filter(page => !this.newPages.some(newPage => newPage.id === page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Add ${this.newPages.length} page(s)`;
}
}
// Command sequence for bulk operations
export class PageCommandSequence implements CommandSequence {
commands: Command[];
description: string;
constructor(commands: Command[], description?: string) {
this.commands = commands;
this.description = description || `Execute ${commands.length} operations`;
}
execute(): void {
this.commands.forEach(command => command.execute());
}
undo(): void {
// Undo in reverse order
[...this.commands].reverse().forEach(command => command.undo());
}
}

View File

@ -0,0 +1,276 @@
/* =========================
FileEditor Card UI Styles
========================= */
.card {
background: var(--file-card-bg);
border-radius: 0.0625rem;
cursor: pointer;
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: hidden;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card[data-selected="true"] {
box-shadow: var(--shadow-sm);
}
/* While dragging */
.card.dragging,
.card:global(.dragging) {
outline: 1px solid var(--border-strong);
box-shadow: var(--shadow-md);
transform: none !important;
}
/* -------- Header -------- */
.header {
height: 2.25rem;
border-radius: 0.0625rem 0.0625rem 0 0;
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
padding: 0 6px;
user-select: none;
background: var(--bg-toolbar);
color: var(--text-primary);
border-bottom: 1px solid var(--border-default);
}
.headerResting {
background: #3B4B6E; /* dark blue for unselected in light mode */
color: #FFFFFF;
border-bottom: 1px solid var(--border-default);
}
.headerSelected {
background: var(--header-selected-bg);
color: var(--header-selected-fg);
border-bottom: 1px solid var(--header-selected-bg);
}
/* Selected border color in light mode */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: var(--card-selected-border);
}
/* Reserve space for checkbox instead of logo */
.logoMark {
margin-left: 8px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.headerIndex {
text-align: center;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.04em;
}
.kebab {
justify-self: end;
}
/* Menu dropdown */
.menuDropdown {
min-width: 210px;
}
/* -------- Title / Meta -------- */
.title {
line-height: 1.2;
color: var(--text-primary);
}
.meta {
margin-top: 2px;
color: var(--text-secondary);
}
/* -------- Preview area -------- */
.previewBox {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--file-card-bg);
}
.previewPaper {
width: 100%;
height: calc(100% - 6px);
min-height: 9rem;
justify-content: center;
display: grid;
position: relative;
overflow: hidden;
background: var(--file-card-bg);
}
/* Thumbnail fallback */
.previewPaper[data-thumb-missing="true"]::after {
content: "No preview";
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
/* Drag handle grip */
.dragHandle {
position: absolute;
bottom: 6px;
right: 6px;
color: var(--text-secondary);
z-index: 1;
cursor: grab;
display: inline-flex;
}
/* Actions Overlay */
.actionsOverlay {
position: absolute;
left: 0;
top: 44px; /* just below header */
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-default);
z-index: 20;
overflow: hidden;
animation: slideDown 140ms ease-out;
color: var(--text-primary);
}
@keyframes slideDown {
from {
transform: translateY(-8px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.actionRow {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
text-align: left;
}
.actionRow:hover {
background: var(--hover-bg);
}
.actionDanger {
color: var(--text-brand-accent);
}
.actionsDivider {
height: 1px;
background: var(--border-default);
margin: 4px 0;
}
/* Pin indicator */
.pinIndicator {
position: absolute;
bottom: 4px;
left: 4px;
z-index: 1;
color: rgba(0, 0, 0, 0.35);
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
background: #6B7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
height: 20px;
}
/* Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 1s infinite;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.card,
.menuDropdown {
transition: none !important;
}
}
/* =========================
DARK MODE OVERRIDES
========================= */
:global([data-mantine-color-scheme="dark"]) .card {
outline-color: #3A4047; /* deselected stroke */
}
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
outline-color: #4B525A; /* selected stroke (subtle grey) */
}
:global([data-mantine-color-scheme="dark"]) .headerResting {
background: #1F2329; /* requested default unselected color */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border); /* #3A4047 */
}
:global([data-mantine-color-scheme="dark"]) .headerSelected {
background: var(--tool-header-border); /* #3A4047 */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border);
}
:global([data-mantine-color-scheme="dark"]) .title {
color: #D0D6DC; /* title text */
}
:global([data-mantine-color-scheme="dark"]) .meta {
color: #6B7280; /* subtitle text */
}
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3B4B6E;
}

View File

@ -12,8 +12,8 @@ import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail';
import styles from './FileEditor.module.css';
import FileEditorThumbnail from './FileEditorThumbnail';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
@ -527,7 +527,7 @@ const FileEditor = ({
if (!fileItem) return null;
return (
<FileThumbnail
<FileEditorThumbnail
key={record.id}
file={fileItem}
index={index}

View File

@ -0,0 +1,407 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string | null;
size: number;
modifiedAt?: number | string | Date;
}
interface FileEditorThumbnailProps {
file: FileItem;
index: number;
totalFiles: number;
selectedFiles: string[];
selectionMode: boolean;
onToggleFile: (fileId: string) => void;
onDeleteFile: (fileId: string) => void;
onViewFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
onDownloadFile?: (fileId: string) => void;
toolMode?: boolean;
isSupported?: boolean;
}
const FileEditorThumbnail = ({
file,
index,
selectedFiles,
onToggleFile,
onDeleteFile,
onViewFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
const [showActions, setShowActions] = useState(false);
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size);
}, [activeFiles, file.name, file.size]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
// Prefer parent-provided handler if available
if (typeof onDownloadFile === 'function') {
onDownloadFile(file.id);
return;
}
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
const link = document.createElement('a');
link.href = URL.createObjectURL(maybeFile);
link.download = maybeFile.name || file.name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
return;
}
// If we can't find a way to download, surface a status message
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
}, [file, onDownloadFile, onSetStatus, t]);
const handleRef = useRef<HTMLSpanElement | null>(null);
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Meta formatting ----
const prettySize = useMemo(() => {
const bytes = file.size ?? 0;
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}, [file.size]);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toUpperCase();
}, [file.name]);
const pageLabel = useMemo(
() =>
file.pageCount > 0
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
: '',
[file.pageCount]
);
const dateLabel = useMemo(() => {
const d =
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
if (Number.isNaN(d.getTime())) return '';
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
year: 'numeric',
}).format(d);
}, [file.modifiedAt]);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id] // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as string;
const selectedFileIds = sourceData.selectedFiles as string[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectedFiles, onReorderFiles]);
// Update dropdown width on resize
useEffect(() => {
const update = () => {
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// Close the actions dropdown when hovering outside this file card (and its dropdown)
useEffect(() => {
if (!showActions) return;
const isInsideCard = (target: EventTarget | null) => {
const container = dragElementRef.current;
if (!container) return false;
return target instanceof Node && container.contains(target);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
const handleTouchStart = (e: TouchEvent) => {
// On touch devices, close if the touch target is outside the card
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('touchstart', handleTouchStart, { passive: true });
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('touchstart', handleTouchStart);
};
}, [showActions]);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
onToggleFile(file.id);
};
return (
<div
ref={fileElementRef}
data-file-id={file.id}
data-testid="file-thumbnail"
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)',
}}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${
isSelected ? styles.headerSelected : styles.headerResting
}`}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
color="var(--checkbox-checked-bg)"
/>
) : (
<div className={styles.unsupportedPill}>
<span>
{t('unsupported', 'Unsupported')}
</span>
</div>
)}
</div>
{/* Centered index */}
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
{index + 1}
</div>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Actions overlay */}
{showActions && (
<div
className={styles.actionsOverlay}
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<button
className={styles.actionRow}
onClick={() => {
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
onSetStatus?.(`Unpinned ${file.name}`);
} else {
pinFile(actualFile);
onSetStatus?.(`Pinned ${file.name}`);
}
}
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
</button>
<button
className={styles.actionRow}
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{t('download', 'Download')}</span>
</button>
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
setShowActions(false);
}}
>
<DeleteOutlineIcon fontSize="small" />
<span>{t('delete', 'Delete')}</span>
</button>
</div>
)}
{/* Title + meta line */}
<div
style={{
padding: '0.5rem',
textAlign: 'center',
background: 'var(--file-card-bg)',
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
{file.name}
</Text>
<Text
size="sm"
c="dimmed"
className={styles.meta}
lineClamp={3}
title={`${extUpper || 'FILE'}${prettySize}`}
>
{/* e.g., Jan 29, 2025 - PDF file - 3 Pages */}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ''}
{pageLabel ? ` - ${pageLabel}` : ''}
</Text>
</div>
{/* Preview area */}
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
<div className={styles.previewPaper}>
{file.thumbnail && (
<img
src={file.thumbnail}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
background: '#ffffff',
border: '1px solid var(--border-default)',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
alignSelf: 'start'
}}
/>
)}
</div>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
</div>
</div>
);
};
export default React.memo(FileEditorThumbnail);

View File

@ -111,11 +111,16 @@ export default function Workbench() {
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onExportSelected={pageEditorFunctions.onExportSelected}
onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPages={pageEditorFunctions.selectedPages}
selectedPageIds={pageEditorFunctions.selectedPageIds}
displayDocument={pageEditorFunctions.displayDocument}
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/>
)}
</>

View File

@ -4,14 +4,16 @@ import { Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPages: number[];
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
onUpdatePagesFromCSV: () => void;
}
const BulkSelectionPanel = ({
csvInput,
setCsvInput,
selectedPages,
selectedPageIds,
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
return (
@ -30,9 +32,12 @@ const BulkSelectionPanel = ({
Apply
</Button>
</Group>
{selectedPages.length > 0 && (
{selectedPageIds.length > 0 && (
<Text size="sm" c="dimmed" mt="sm">
Selected: {selectedPages.length} pages
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(n => n > 0).join(', ') : ''})
</Text>
)}
</>

View File

@ -3,18 +3,19 @@ import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
import { GRID_CONSTANTS } from './constants';
interface DragDropItem {
id: string;
splitBefore?: boolean;
splitAfter?: boolean;
}
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
selectedItems: number[];
selectedItems: string[];
selectionMode: boolean;
isAnimating: boolean;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
}
@ -33,10 +34,7 @@ const DragDropGrid = <T extends DragDropItem>({
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const ITEM_WIDTH = 320; // 20rem (page width)
const ITEM_GAP = 24; // 1.5rem gap between items
const ITEM_HEIGHT = 340; // 20rem + gap
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
@ -45,6 +43,11 @@ const DragDropGrid = <T extends DragDropItem>({
const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4; // Container not measured yet
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
@ -82,12 +85,21 @@ const DragDropGrid = <T extends DragDropItem>({
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
estimateSize: () => ITEM_HEIGHT,
estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
},
overscan: OVERSCAN,
});
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
return (
<Box
ref={containerRef}
@ -102,6 +114,8 @@ const DragDropGrid = <T extends DragDropItem>({
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
margin: '0 auto',
maxWidth: `${gridWidth}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
@ -124,18 +138,17 @@ const DragDropGrid = <T extends DragDropItem>({
<div
style={{
display: 'flex',
gap: '1.5rem',
gap: GRID_CONSTANTS.ITEM_GAP,
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
position: 'relative'
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>

View File

@ -16,7 +16,7 @@ interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
thumbnail: string | null;
size: number;
modifiedAt?: number | string | Date;
}
@ -129,9 +129,9 @@ const FileThumbnail = ({
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
@ -147,7 +147,7 @@ const FileThumbnail = ({
setIsDragging(false);
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
@ -331,46 +331,34 @@ const FileThumbnail = ({
</div>
)}
{/* Title + meta line */}
<div
style={{
padding: '0.5rem',
textAlign: 'center',
background: 'var(--file-card-bg)',
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
{file.name}
</Text>
<Text
size="sm"
c="dimmed"
className={styles.meta}
lineClamp={3}
title={`${extUpper || 'FILE'}${prettySize}`}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">
{/* Stacked file effect - multiple shadows to simulate pages */}
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
}}
>
{/* e.g., Jan 29, 2025 - PDF file - 3 Pages */}
{dateLabel}
{extUpper ? ` - ${extUpper} file` : ''}
{pageLabel ? ` - ${pageLabel}` : ''}
</Text>
</div>
{/* Preview area */}
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
<div className={styles.previewPaper}>
<img
src={file.thumbnail}
alt={file.name}
draggable={false}
loading="lazy"
decoding="async"
onError={(e) => {
const img = e.currentTarget;
img.style.display = 'none';
img.parentElement?.setAttribute('data-thumb-missing', 'true');
}}
{file.thumbnail && (
<img
src={file.thumbnail}
alt={file.name}
draggable={false}
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = 'none';
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
@ -384,6 +372,7 @@ const FileThumbnail = ({
alignSelf: 'start'
}}
/>
)}
</div>
{/* Pin indicator (bottom-left) */}

View File

@ -1,244 +1,81 @@
/* =========================
NEW styles for card UI
========================= */
/* Page container hover effects - optimized for smooth scrolling */
.pageContainer {
transition: transform 0.2s ease-in-out;
/* Enable hardware acceleration for smoother scrolling */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.card {
background: var(--file-card-bg);
border-radius: 0.0625rem;
cursor: pointer;
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: hidden;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card[data-selected="true"] {
box-shadow: var(--shadow-sm);
}
/* While dragging */
.card.dragging,
.card:global(.dragging) {
outline: 1px solid var(--border-strong);
box-shadow: var(--shadow-md);
transform: none !important;
}
/* -------- Header -------- */
.header {
height: 2.25rem;
border-radius: 0.0625rem 0.0625rem 0 0;
display: grid;
grid-template-columns: 44px 1fr 44px;
align-items: center;
padding: 0 6px;
user-select: none;
background: var(--bg-toolbar);
color: var(--text-primary);
border-bottom: 1px solid var(--border-default);
}
.headerResting {
background: #3B4B6E; /* dark blue for unselected in light mode */
color: #FFFFFF;
border-bottom: 1px solid var(--border-default);
}
.headerSelected {
background: var(--header-selected-bg);
color: var(--header-selected-fg);
border-bottom: 1px solid var(--header-selected-bg);
}
/* Selected border color in light mode */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: var(--card-selected-border);
}
/* Reserve space for checkbox instead of logo */
.logoMark {
margin-left: 8px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.headerIndex {
text-align: center;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.04em;
}
.kebab {
justify-self: end;
color: inherit;
}
/* Menu dropdown */
.menuDropdown {
min-width: 210px;
}
/* -------- Title / Meta -------- */
.title {
line-height: 1.2;
color: var(--text-primary);
}
.meta {
margin-top: 2px;
color: var(--text-secondary);
}
/* -------- Preview area -------- */
.previewBox {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--file-card-bg);
}
.previewPaper {
width: 100%;
height: calc(100% - 6px);
min-height: 9rem;
justify-content: center;
display: grid;
position: relative;
overflow: hidden;
background: var(--file-card-bg);
}
/* Thumbnail fallback */
.previewPaper[data-thumb-missing="true"]::after {
content: "No preview";
position: absolute; inset: 0;
display: grid; place-items: center;
color: var(--text-secondary);
font-weight: 600; font-size: 12px;
}
/* Drag handle grip */
.dragHandle {
position: absolute;
bottom: 6px;
right: 6px;
color: var(--text-secondary);
z-index: 1;
cursor: grab;
display: inline-flex;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.card,
.menuDropdown {
transition: none !important;
}
}
.pageContainer:hover {
transform: scale(1.02) translateZ(0);
}
/* =========================
DARK MODE OVERRIDES
========================= */
:global([data-mantine-color-scheme="dark"]) .card {
outline-color: #3A4047; /* deselected stroke */
}
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
outline-color: #4B525A; /* selected stroke (subtle grey) */
}
:global([data-mantine-color-scheme="dark"]) .headerResting {
background: #1F2329; /* requested default unselected color */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border); /* #3A4047 */
}
:global([data-mantine-color-scheme="dark"]) .headerSelected {
background: var(--tool-header-border); /* #3A4047 */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border);
}
:global([data-mantine-color-scheme="dark"]) .title {
color: #D0D6DC; /* title text */
}
:global([data-mantine-color-scheme="dark"]) .meta {
color: #6B7280; /* subtitle text */
}
.pageContainer:hover .pageNumber {
opacity: 1 !important;
}
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3B4B6E;
}
/* =========================
(Optional) legacy styles from your
previous component kept here to
avoid breaking other imports.
They are not used by the new card.
========================= */
.pageContainer {
transition: transform 0.2s ease-in-out;
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.pageContainer:hover { transform: scale(1.02) translateZ(0); }
.pageContainer:hover .pageNumber { opacity: 1 !important; }
.pageContainer:hover .pageHoverControls { opacity: 1 !important; }
.checkboxContainer { transform: none !important; transition: none !important; }
.pageMoveAnimation { transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); }
.pageMoving { z-index: 10; transform: scale(1.05); box-shadow: 0 10px 30px rgba(0,0,0,0.3); }
.multiDragIndicator {
position: fixed;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
backdrop-filter: blur(4px);
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
.pulse { animation: pulse 1s infinite; }
.pageContainer:hover .pageHoverControls {
opacity: 0.95 !important;
}
.actionsOverlay {
position: absolute;
left: 0;
top: 44px; /* just below header */
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-default);
z-index: 20;
overflow: hidden;
animation: slideDown 140ms ease-out;
color: var(--text-primary);
}
@keyframes slideDown { from { transform: translateY(-8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
/* Checkbox container - prevent transform inheritance */
.checkboxContainer {
transform: none !important;
transition: none !important;
}
.actionRow {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
text-align: left;
/* Page movement animations */
.pageMoveAnimation {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.pageMoving {
z-index: 10;
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
/* Multi-page drag indicator */
.multiDragIndicator {
position: fixed;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
backdrop-filter: blur(4px);
}
/* Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
.actionRow:hover { background: var(--hover-bg); }
.actionDanger { color: var(--text-brand-accent); }
.actionsDivider { height: 1px; background: var(--border-default); margin: 4px 0; }
50% {
opacity: 0.5;
}
}
/* Action styles */
.actionRow:hover {
background: var(--hover-bg);
}
.actionDanger {
color: var(--text-brand-accent);
}
.actionsDivider {
height: 1px;
background: var(--border-default);
margin: 4px 0;
}
.pinIndicator {
position: absolute;
@ -262,4 +99,3 @@
min-width: 80px;
height: 20px;
}

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,10 @@ import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import DownloadIcon from "@mui/icons-material/Download";
interface PageEditorControlsProps {
// Close/Reset functions
@ -23,27 +27,87 @@ interface PageEditorControlsProps {
onRotate: (direction: 'left' | 'right') => void;
onDelete: () => void;
onSplit: () => void;
onSplitAll: () => void;
onPageBreak: () => void;
onPageBreakAll: () => void;
// Export functions
onExportSelected: () => void;
// Export functions (moved to right rail)
onExportAll: () => void;
exportLoading: boolean;
// Selection state
selectionMode: boolean;
selectedPages: number[];
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
// Split state (for tooltip logic)
splitPositions?: Set<number>;
totalPages?: number;
}
const PageEditorControls = ({
onClosePdf,
onUndo,
onRedo,
canUndo,
canRedo,
onRotate,
onDelete,
onSplit,
onSplitAll,
onPageBreak,
onPageBreakAll,
onExportAll,
exportLoading,
selectionMode,
selectedPages
selectedPageIds,
displayDocument,
splitPositions,
totalPages
}: PageEditorControlsProps) => {
// Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => {
if (!splitPositions || !totalPages || selectedPageIds.length === 0) {
return "Split Selected";
}
// Convert selected pages to split positions (same logic as handleSplit)
const selectedPageNumbers = displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0) : [];
const selectedSplitPositions = selectedPageNumbers.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1);
if (selectedSplitPositions.length === 0) {
return "Split Selected";
}
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedSplitPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const willRemoveSplits = existingSplitsCount > noSplitsCount;
if (willRemoveSplits) {
return existingSplitsCount === selectedSplitPositions.length
? "Remove All Selected Splits"
: "Remove Selected Splits";
} else {
return existingSplitsCount === 0
? "Split Selected"
: "Complete Selected Splits";
}
};
// Calculate page break tooltip text
const getPageBreakTooltip = () => {
return selectedPageIds.length > 0
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? 's' : ''}`
: "Insert Page Breaks";
};
return (
<div
style={{
@ -72,7 +136,7 @@ const PageEditorControls = ({
border: '1px solid var(--border-default)',
borderRadius: '16px 16px 0 0',
pointerEvents: 'auto',
minWidth: 420,
minWidth: 360,
maxWidth: 700,
flexWrap: 'wrap',
justifyContent: 'center',
@ -83,12 +147,12 @@ const PageEditorControls = ({
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg">
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg">
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
<RedoIcon />
</ActionIcon>
</Tooltip>
@ -96,40 +160,66 @@ const PageEditorControls = ({
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Page Operations */}
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
<Tooltip label="Rotate Selected Left">
<ActionIcon
onClick={() => onRotate('left')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
<Tooltip label="Rotate Selected Right">
<ActionIcon
onClick={() => onRotate('right')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
<Tooltip label="Delete Selected">
<ActionIcon
onClick={onDelete}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getSplitTooltip()}>
<ActionIcon
onClick={onSplit}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getPageBreakTooltip()}>
<ActionIcon
onClick={onPageBreak}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<InsertPageBreakIcon />
</ActionIcon>
</Tooltip>
</div>
</div>
);

View File

@ -1,160 +1,171 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core';
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import AddIcon from '@mui/icons-material/Add';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo';
import { useFileState } from '../../contexts/FileContext';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import styles from './PageEditor.module.css';
interface PageThumbnailProps {
page: PDFPage;
index: number;
totalPages: number;
originalFile?: File; // For lazy thumbnail generation
selectedPages: number[];
originalFile?: File;
selectedPageIds: string[];
selectionMode: boolean;
movingPage: number | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onTogglePage: (pageNumber: number) => void;
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
onExecuteCommand: (command: Command) => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: () => void;
onExecuteCommand: (command: { execute: () => void }) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (pageNumber: number | null) => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
RotatePagesCommand: typeof RotatePagesCommand;
DeletePagesCommand: typeof DeletePagesCommand;
ToggleSplitCommand: typeof ToggleSplitCommand;
onSetMovingPage: (page: number | null) => void;
onDeletePage: (pageNumber: number) => void;
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
createSplitCommand: (position: number) => { execute: () => void };
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<number>;
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
}
const PageThumbnail = React.memo(({
const PageThumbnail: React.FC<PageThumbnailProps> = ({
page,
index,
totalPages,
originalFile,
selectedPages,
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
pageRefs,
onReorderPages,
onTogglePage,
onAnimateReorder,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
onReorderPages,
RotatePagesCommand,
DeletePagesCommand,
ToggleSplitCommand,
onDeletePage,
createRotateCommand,
createDeleteCommand,
createSplitCommand,
pdfDocument,
setPdfDocument,
splitPositions,
onInsertFiles,
}: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const dragElementRef = useRef<HTMLDivElement>(null);
const { state, selectors } = useFileState();
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
const { openFilesModal } = useFilesModalContext();
// Update thumbnail URL when page prop changes - prevent redundant updates
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio
const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail);
if (firstRealPage?.thumbnail) {
// Try to get aspect ratio from an actual thumbnail image
// For now, default to A4 but could be enhanced to measure image dimensions
return '1 / 1.414'; // A4 ratio as fallback
}
return '1 / 1.414'; // Default A4 ratio
}, [pdfDocument.pages]);
// Update thumbnail URL when page prop changes
useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
setThumbnailUrl(page.thumbnail);
}
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
}, [page.thumbnail, thumbnailUrl]);
// Request thumbnail generation if not available (optimized for performance)
// Request thumbnail if missing (on-demand, virtualized approach)
useEffect(() => {
if (thumbnailUrl || !originalFile) {
return; // Skip if we already have a thumbnail or no original file
let isCancelled = false;
// If we already have a thumbnail, use it
if (page.thumbnail) {
setThumbnailUrl(page.thumbnail);
return;
}
// Check cache first without async call
// Check cache first
const cachedThumbnail = getThumbnailFromCache(page.id);
if (cachedThumbnail) {
setThumbnailUrl(cachedThumbnail);
return;
}
let cancelled = false;
// Request thumbnail generation if we have the original file
if (originalFile) {
const pageNumber = page.originalPageNumber;
const loadThumbnail = async () => {
try {
const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber);
// Only update if component is still mounted and we got a result
if (!cancelled && thumbnail) {
setThumbnailUrl(thumbnail);
}
} catch (error) {
if (!cancelled) {
console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error);
}
}
};
requestThumbnail(page.id, originalFile, pageNumber)
.then(thumbnail => {
if (!isCancelled && thumbnail) {
setThumbnailUrl(thumbnail);
}
})
.catch(error => {
console.warn(`Failed to generate thumbnail for ${page.id}:`, error);
});
}
loadThumbnail();
// Cleanup function to prevent state updates after unmount
return () => {
cancelled = true;
isCancelled = true;
};
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
if (element) {
pageRefs.current.set(page.id, element);
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
pageNumber: page.pageNumber,
pageId: page.id,
selectedPages: selectionMode && selectedPages.includes(page.pageNumber)
? selectedPages
: [page.pageNumber]
selectedPageIds: [page.id]
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ location }) => {
setIsDragging(false);
if (location.current.dropTargets.length === 0) {
return;
}
const dropTarget = location.current.dropTargets[0];
const targetData = dropTarget.data;
if (targetData.type === 'page') {
const targetPageNumber = targetData.pageNumber as number;
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
if (targetIndex !== -1) {
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
? selectedPages
: undefined;
onReorderPages(page.pageNumber, targetIndex, pagesToMove);
onReorderPages(page.pageNumber, targetIndex, undefined);
}
}
}
});
element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
@ -163,7 +174,7 @@ const PageThumbnail = React.memo(({
}),
onDrop: ({ source }) => {}
});
(element as any).__dragCleanup = () => {
dragCleanup();
dropCleanup();
@ -174,17 +185,105 @@ const PageThumbnail = React.memo(({
(dragElementRef.current as any).__dragCleanup();
}
}
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
// DOM command handlers
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], -90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleRotateRight = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], 90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleDelete = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onDeletePage(page.pageNumber);
onSetStatus(`Deleted page ${page.pageNumber}`);
}, [page.pageNumber, onDeletePage, onSetStatus]);
const handleSplit = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Create a command to toggle split at this position
const command = createSplitCommand(index);
onExecuteCommand(command);
const hasSplit = splitPositions.has(index);
const action = hasSplit ? 'removed' : 'added';
onSetStatus(`Split marker ${action} after position ${index + 1}`);
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (onInsertFiles) {
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (files: File[], insertAfterPage?: number) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage);
}
}
});
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
} else {
// Fallback to normal file handling
openFilesModal({ insertAfterPage: page.pageNumber });
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
}
}, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]);
// Handle click vs drag differentiation
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsMouseDown(true);
setMouseStartPos({ x: e.clientX, y: e.clientY });
}, []);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (!isMouseDown || !mouseStartPos) {
setIsMouseDown(false);
setMouseStartPos(null);
return;
}
// Calculate distance moved
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// If mouse moved less than 5 pixels, consider it a click (not a drag)
if (distance < 5 && !isDragging) {
onTogglePage(page.id);
}
setIsMouseDown(false);
setMouseStartPos(null);
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
const handleMouseLeave = useCallback(() => {
setIsMouseDown(false);
setMouseStartPos(null);
}, []);
return (
<div
ref={pageElementRef}
data-page-id={page.id}
data-page-number={page.pageNumber}
className={`
${styles.pageContainer}
!rounded-lg
cursor-grab
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
select-none
w-[20rem]
h-[20rem]
@ -204,6 +303,9 @@ const PageThumbnail = React.memo(({
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable={false}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
>
{
<div
@ -217,25 +319,25 @@ const PageThumbnail = React.memo(({
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto',
cursor: 'pointer'
pointerEvents: 'auto'
}}
onMouseDown={(e) => e.stopPropagation()}
onMouseDown={(e) => {
e.stopPropagation();
onTogglePage(page.id);
}}
onMouseUp={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
>
<Checkbox
checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false}
checked={Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false}
onChange={() => {
// onChange is handled by the parent div click
// Selection is handled by container mouseDown
}}
size="sm"
style={{ pointerEvents: 'none' }}
/>
</div>
}
@ -254,7 +356,23 @@ const PageThumbnail = React.memo(({
justifyContent: 'center'
}}
>
{thumbnailUrl ? (
{page.isBlankPage ? (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: '70%',
aspectRatio: getDocumentAspectRatio(),
backgroundColor: 'white',
border: '1px solid #e9ecef',
borderRadius: 2
}}></div>
</div>
) : thumbnailUrl ? (
<img
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
@ -280,12 +398,12 @@ const PageThumbnail = React.memo(({
className={styles.pageNumber}
size="sm"
fw={500}
c="white"
style={{
color: 'var(--mantine-color-white)', // Use theme token for consistency
position: 'absolute',
top: 5,
left: 5,
background: 'rgba(162, 201, 255, 0.8)',
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
borderRadius: 8,
zIndex: 2,
@ -303,7 +421,8 @@ const PageThumbnail = React.memo(({
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
padding: '6px 12px',
borderRadius: 20,
opacity: 0,
@ -314,19 +433,23 @@ const PageThumbnail = React.memo(({
alignItems: 'center',
whiteSpace: 'nowrap'
}}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Tooltip label="Move Left">
<ActionIcon
size="md"
variant="subtle"
c="white"
style={{ color: 'var(--mantine-color-dimmed)' }}
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onAnimateReorder(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 500);
// Actually move the page left (swap with previous page)
onReorderPages(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
}}
@ -339,14 +462,15 @@ const PageThumbnail = React.memo(({
<ActionIcon
size="md"
variant="subtle"
c="white"
style={{ color: 'var(--mantine-color-dimmed)' }}
disabled={index === totalPages - 1}
onClick={(e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onAnimateReorder(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 500);
// Actually move the page right (swap with next page)
onReorderPages(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
}}
@ -359,18 +483,8 @@ const PageThumbnail = React.memo(({
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
-90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}}
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleRotateLeft}
>
<RotateLeftIcon style={{ fontSize: 20 }} />
</ActionIcon>
@ -380,18 +494,8 @@ const PageThumbnail = React.memo(({
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}}
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleRotateRight}
>
<RotateRightIcon style={{ fontSize: 20 }} />
</ActionIcon>
@ -402,66 +506,41 @@ const PageThumbnail = React.memo(({
size="md"
variant="subtle"
c="red"
onClick={(e) => {
e.stopPropagation();
const command = new DeletePagesCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Deleted page ${page.pageNumber}`);
}}
onClick={handleDelete}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
{index > 0 && (
<Tooltip label="Split Here">
{index < totalPages - 1 && (
<Tooltip label="Split After">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new ToggleSplitCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
}}
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleSplit}
>
<ContentCutIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Insert File After">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleInsertFileAfter}
>
<AddIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</div>
</div>
</div>
);
}, (prevProps, nextProps) => {
// Helper for shallow array comparison
const arraysEqual = (a: number[], b: number[]) => {
return a.length === b.length && a.every((val, i) => val === b[i]);
};
// Only re-render if essential props change
return (
prevProps.page.id === nextProps.page.id &&
prevProps.page.pageNumber === nextProps.page.pageNumber &&
prevProps.page.rotation === nextProps.page.rotation &&
prevProps.page.thumbnail === nextProps.page.thumbnail &&
// Shallow compare selectedPages array for better stability
(prevProps.selectedPages === nextProps.selectedPages ||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
prevProps.selectionMode === nextProps.selectionMode &&
prevProps.movingPage === nextProps.movingPage &&
prevProps.isAnimating === nextProps.isAnimating
);
});
};
export default PageThumbnail;

View File

@ -0,0 +1,879 @@
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
// V1-style DOM-first command system (replaces the old React state commands)
export abstract class DOMCommand {
abstract execute(): void;
abstract undo(): void;
abstract description: string;
}
export class RotatePageCommand extends DOMCommand {
constructor(
private pageId: string,
private degrees: number
) {
super();
}
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;
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;
img.style.transform = `rotate(${previousRotation}deg)`;
}
}
}
get description(): string {
return `Rotate page ${this.degrees > 0 ? 'right' : 'left'}`;
}
}
export class DeletePagesCommand extends DOMCommand {
private originalDocument: PDFDocument | null = null;
private originalSplitPositions: Set<number> = new Set();
private originalSelectedPages: number[] = [];
private hasExecuted: boolean = false;
private pageIdsToDelete: string[] = [];
constructor(
private pagesToDelete: number[],
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void,
private getSelectedPages: () => number[]
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.pagesToDelete.length === 0) return;
// Store complete original state for undo (only on first execution)
if (!this.hasExecuted) {
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page})) // Deep copy pages
};
this.originalSplitPositions = new Set(this.getSplitPositions());
this.originalSelectedPages = [...this.getSelectedPages()];
// Convert page numbers to page IDs for stable identification
this.pageIdsToDelete = this.pagesToDelete.map(pageNum => {
const page = currentDoc.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id);
this.hasExecuted = true;
}
// Filter out deleted pages by ID (stable across undo/redo)
const remainingPages = currentDoc.pages.filter(page =>
!this.pageIdsToDelete.includes(page.id)
);
if (remainingPages.length === 0) return; // Safety check
// Renumber remaining pages
remainingPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: remainingPages,
totalPages: remainingPages.length,
};
// Adjust split positions
const currentSplitPositions = this.getSplitPositions();
const newPositions = new Set<number>();
currentSplitPositions.forEach(pos => {
if (pos < remainingPages.length - 1) {
newPositions.add(pos);
}
});
// Apply changes
this.setDocument(updatedDocument);
this.setSelectedPages([]);
this.setSplitPositions(newPositions);
}
undo(): void {
if (!this.originalDocument) return;
// Simply restore the complete original document state
this.setDocument(this.originalDocument);
this.setSplitPositions(this.originalSplitPositions);
this.setSelectedPages(this.originalSelectedPages);
}
get description(): string {
return `Delete ${this.pagesToDelete.length} page(s)`;
}
}
export class ReorderPagesCommand extends DOMCommand {
private originalPages: PDFPage[] = [];
constructor(
private sourcePageNumber: number,
private targetIndex: number,
private selectedPages: number[] | undefined,
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc) return;
// Store original state for undo
this.originalPages = currentDoc.pages.map(page => ({...page}));
// Perform the reorder
const sourceIndex = currentDoc.pages.findIndex(p => p.pageNumber === this.sourcePageNumber);
if (sourceIndex === -1) return;
const newPages = [...currentDoc.pages];
if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) {
// Multi-page reorder
const selectedPageObjects = this.selectedPages
.map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum))
.filter(page => page !== undefined) as PDFPage[];
const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber));
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
remainingPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
newPages.splice(0, newPages.length, ...remainingPages);
} else {
// Single page reorder
const [movedPage] = newPages.splice(sourceIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
newPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
}
const reorderedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(reorderedDocument);
}
undo(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.originalPages.length === 0) return;
// Restore original page order
const restoredDocument: PDFDocument = {
...currentDoc,
pages: this.originalPages,
totalPages: this.originalPages.length,
};
this.setDocument(restoredDocument);
}
get description(): string {
return `Reorder page(s)`;
}
}
export class SplitCommand extends DOMCommand {
private originalSplitPositions: Set<number> = new Set();
constructor(
private position: number,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void
) {
super();
}
execute(): void {
// Store original state for undo
this.originalSplitPositions = new Set(this.getSplitPositions());
// Toggle the split position
const currentPositions = this.getSplitPositions();
const newPositions = new Set(currentPositions);
if (newPositions.has(this.position)) {
newPositions.delete(this.position);
} else {
newPositions.add(this.position);
}
this.setSplitPositions(newPositions);
}
undo(): void {
// Restore original split positions
this.setSplitPositions(this.originalSplitPositions);
}
get description(): string {
const currentPositions = this.getSplitPositions();
const willAdd = !currentPositions.has(this.position);
return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`;
}
}
export class BulkRotateCommand extends DOMCommand {
private originalRotations: Map<string, number> = new Map();
constructor(
private pageIds: string[],
private degrees: number
) {
super();
}
execute(): void {
this.pageIds.forEach(pageId => {
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Store original rotation for undo (only on first execution)
if (!this.originalRotations.has(pageId)) {
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
this.originalRotations.set(pageId, currentRotation);
}
// Apply rotation using transform to trigger CSS animation
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
const newRotation = currentRotation + this.degrees;
img.style.transform = `rotate(${newRotation}deg)`;
}
}
});
}
undo(): void {
this.pageIds.forEach(pageId => {
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img && this.originalRotations.has(pageId)) {
img.style.transform = `rotate(${this.originalRotations.get(pageId)}deg)`;
}
}
});
}
get description(): string {
return `Rotate ${this.pageIds.length} page(s) ${this.degrees > 0 ? 'right' : 'left'}`;
}
}
export class BulkSplitCommand extends DOMCommand {
private originalSplitPositions: Set<number> = new Set();
constructor(
private positions: number[],
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void
) {
super();
}
execute(): void {
// Store original state for undo (only on first execution)
if (this.originalSplitPositions.size === 0) {
this.originalSplitPositions = new Set(this.getSplitPositions());
}
// Toggle each position
const currentPositions = new Set(this.getSplitPositions());
this.positions.forEach(position => {
if (currentPositions.has(position)) {
currentPositions.delete(position);
} else {
currentPositions.add(position);
}
});
this.setSplitPositions(currentPositions);
}
undo(): void {
// Restore original split positions
this.setSplitPositions(this.originalSplitPositions);
}
get description(): string {
return `Toggle ${this.positions.length} split position(s)`;
}
}
export class SplitAllCommand extends DOMCommand {
private originalSplitPositions: Set<number> = new Set();
private allPossibleSplits: Set<number> = new Set();
constructor(
private totalPages: number,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void
) {
super();
// Calculate all possible split positions (between pages, not after last page)
for (let i = 0; i < this.totalPages - 1; i++) {
this.allPossibleSplits.add(i);
}
}
execute(): void {
// Store original state for undo
this.originalSplitPositions = new Set(this.getSplitPositions());
// Check if all splits are already active
const currentSplits = this.getSplitPositions();
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
if (hasAllSplits) {
// Remove all splits
this.setSplitPositions(new Set());
} else {
// Add all splits
this.setSplitPositions(this.allPossibleSplits);
}
}
undo(): void {
// Restore original split positions
this.setSplitPositions(this.originalSplitPositions);
}
get description(): string {
const currentSplits = this.getSplitPositions();
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
return hasAllSplits ? 'Remove all splits' : 'Split all pages';
}
}
export class PageBreakCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
constructor(
private selectedPageNumbers: number[],
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.selectedPageNumbers.length === 0) return;
// Store original state for undo
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page}))
};
// Create new pages array with blank pages inserted
const newPages: PDFPage[] = [];
this.insertedPages = [];
let pageNumberCounter = 1;
currentDoc.pages.forEach((page, index) => {
// Add the current page
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
newPages.push(updatedPage);
// If this page is selected for page break insertion, add a blank page after it
if (this.selectedPageNumbers.includes(page.pageNumber)) {
const blankPage: PDFPage = {
id: `blank-${Date.now()}-${index}`,
pageNumber: pageNumberCounter++,
originalPageNumber: -1, // Mark as blank page
thumbnail: null,
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: true // Custom flag for blank pages
};
newPages.push(blankPage);
this.insertedPages.push(blankPage);
}
});
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(updatedDocument);
// No need to maintain selection - page IDs remain stable, so selection persists automatically
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert ${this.selectedPageNumbers.length} page break(s)`;
}
}
export class BulkPageBreakCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
private originalSelectedPages: number[] = [];
constructor(
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private getSelectedPages: () => number[]
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc) return;
// Store original selection to restore later
this.originalSelectedPages = this.getSelectedPages();
// Store original state for undo
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page}))
};
// Create new pages array with blank pages inserted after each page (except the last)
const newPages: PDFPage[] = [];
this.insertedPages = [];
let pageNumberCounter = 1;
currentDoc.pages.forEach((page, index) => {
// Add the current page
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
newPages.push(updatedPage);
// Add blank page after each page except the last one
if (index < currentDoc.pages.length - 1) {
const blankPage: PDFPage = {
id: `blank-${Date.now()}-${index}`,
pageNumber: pageNumberCounter++,
originalPageNumber: -1,
thumbnail: null,
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: true
};
newPages.push(blankPage);
this.insertedPages.push(blankPage);
}
});
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(updatedDocument);
// Maintain existing selection by mapping original selected pages to their new positions
const updatedSelection: number[] = [];
this.originalSelectedPages.forEach(originalPageNum => {
// Find the original page by matching the page ID from the original document
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
if (originalPage) {
const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage);
if (foundPage) {
updatedSelection.push(foundPage.pageNumber);
}
}
});
this.setSelectedPages(updatedSelection);
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert page breaks after all pages`;
}
}
export class InsertFilesCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
private fileDataMap = new Map<string, ArrayBuffer>(); // Store file data for thumbnail generation
private originalProcessedFile: any = null; // Store original ProcessedFile for undo
private insertedFileMap = new Map<string, File>(); // Store inserted files for export
constructor(
private files: File[],
private insertAfterPageNumber: number,
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private getSelectedPages: () => number[],
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<string, File>) => void
) {
super();
}
async execute(): Promise<void> {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.files.length === 0) return;
// Store original state for undo
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page}))
};
try {
// Process each file to extract pages and wait for all to complete
const allNewPages: PDFPage[] = [];
// Process all files and wait for their completion
const baseTimestamp = Date.now();
const extractionPromises = this.files.map(async (file, index) => {
const fileId = `inserted-${file.name}-${baseTimestamp + index}`;
// Store inserted file for export
this.insertedFileMap.set(fileId, file);
// Use base timestamp + index to ensure unique but predictable file IDs
return await this.extractPagesFromFile(file, baseTimestamp + index);
});
const extractedPageArrays = await Promise.all(extractionPromises);
// Flatten all extracted pages
for (const pages of extractedPageArrays) {
allNewPages.push(...pages);
}
if (allNewPages.length === 0) return;
// Find insertion point (after the specified page)
const insertIndex = this.insertAfterPageNumber; // Insert after page N means insert at index N
// Create new pages array with inserted pages
const newPages: PDFPage[] = [];
let pageNumberCounter = 1;
// Add pages before insertion point
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
newPages.push(page);
}
// Add inserted pages
for (const newPage of allNewPages) {
const insertedPage: PDFPage = {
...newPage,
pageNumber: pageNumberCounter++,
selected: false,
splitAfter: false
};
newPages.push(insertedPage);
this.insertedPages.push(insertedPage);
}
// Add remaining pages after insertion point
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
newPages.push(page);
}
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(updatedDocument);
// Update FileContext with the new document structure and inserted files
if (this.updateFileContext) {
this.updateFileContext(updatedDocument, this.insertedFileMap);
}
// Generate thumbnails for inserted pages (all files should be read by now)
this.generateThumbnailsForInsertedPages(updatedDocument);
// Maintain existing selection by mapping original selected pages to their new positions
const originalSelection = this.getSelectedPages();
const updatedSelection: number[] = [];
originalSelection.forEach(originalPageNum => {
if (originalPageNum <= this.insertAfterPageNumber) {
// Pages before insertion point keep same number
updatedSelection.push(originalPageNum);
} else {
// Pages after insertion point are shifted by number of inserted pages
updatedSelection.push(originalPageNum + allNewPages.length);
}
});
this.setSelectedPages(updatedSelection);
} catch (error) {
console.error('Failed to insert files:', error);
// Revert to original state if error occurs
if (this.originalDocument) {
this.setDocument(this.originalDocument);
}
}
}
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
try {
const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService');
// Group pages by file ID to generate thumbnails efficiently
const pagesByFileId = new Map<string, PDFPage[]>();
for (const page of this.insertedPages) {
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-'));
if (!pagesByFileId.has(fileId)) {
pagesByFileId.set(fileId, []);
}
pagesByFileId.get(fileId)!.push(page);
}
// Generate thumbnails for each file
for (const [fileId, pages] of pagesByFileId) {
const arrayBuffer = this.fileDataMap.get(fileId);
console.log('Generating thumbnails for file:', fileId);
console.log('Pages:', pages.length);
console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (arrayBuffer && arrayBuffer.byteLength > 0) {
// Extract page numbers for all pages from this file
const pageNumbers = pages.map(page => {
const pageNumMatch = page.id.match(/-page-(\d+)$/);
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
});
console.log('Generating thumbnails for page numbers:', pageNumbers);
// Generate thumbnails for all pages from this file at once
const results = await thumbnailGenerationService.generateThumbnails(
fileId,
arrayBuffer,
pageNumbers,
{ scale: 0.2, quality: 0.8 }
);
console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
// Update pages with generated thumbnails
for (let i = 0; i < results.length && i < pages.length; i++) {
const result = results[i];
const page = pages[i];
if (result.success) {
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
if (pageIndex >= 0) {
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
console.log('Updated thumbnail for page:', page.id);
}
}
}
// Trigger re-render by updating the document
this.setDocument({ ...updatedDocument });
} else {
console.error('No valid ArrayBuffer found for file ID:', fileId);
}
}
} catch (error) {
console.error('Failed to generate thumbnails for inserted pages:', error);
}
}
private async extractPagesFromFile(file: File, baseTimestamp: number): Promise<PDFPage[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const arrayBuffer = event.target?.result as ArrayBuffer;
console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (!arrayBuffer) {
reject(new Error('Failed to read file'));
return;
}
// Clone the ArrayBuffer before passing to PDF.js (it might consume it)
const clonedArrayBuffer = arrayBuffer.slice(0);
// Use PDF.js via the worker manager to extract pages
const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
const pageCount = pdf.numPages;
const pages: PDFPage[] = [];
const fileId = `inserted-${file.name}-${baseTimestamp}`;
console.log('Original ArrayBuffer size:', arrayBuffer.byteLength);
console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength);
// Store the original ArrayBuffer for thumbnail generation
this.fileDataMap.set(fileId, arrayBuffer);
console.log('After storing - fileDataMap size:', this.fileDataMap.size);
console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined');
for (let i = 1; i <= pageCount; i++) {
const pageId = `${fileId}-page-${i}`;
pages.push({
id: pageId,
pageNumber: i, // Will be renumbered in execute()
originalPageNumber: i,
thumbnail: null, // Will be generated after insertion
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: false
});
}
// Clean up PDF document
pdfWorkerManager.destroyDocument(pdf);
resolve(pages);
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsArrayBuffer(file);
});
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert ${this.files.length} file(s) after page ${this.insertAfterPageNumber}`;
}
}
// Simple undo manager for DOM commands
export class UndoManager {
private undoStack: DOMCommand[] = [];
private redoStack: DOMCommand[] = [];
private onStateChange?: () => void;
setStateChangeCallback(callback: () => void): void {
this.onStateChange = callback;
}
executeCommand(command: DOMCommand): void {
command.execute();
this.undoStack.push(command);
this.redoStack = [];
this.onStateChange?.();
}
// For async commands that need to be executed manually
addToUndoStack(command: DOMCommand): void {
this.undoStack.push(command);
this.redoStack = [];
this.onStateChange?.();
}
undo(): boolean {
const command = this.undoStack.pop();
if (command) {
command.undo();
this.redoStack.push(command);
this.onStateChange?.();
return true;
}
return false;
}
redo(): boolean {
const command = this.redoStack.pop();
if (command) {
command.execute();
this.undoStack.push(command);
this.onStateChange?.();
return true;
}
return false;
}
canUndo(): boolean {
return this.undoStack.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.undoStack = [];
this.redoStack = [];
this.onStateChange?.();
}
}

View File

@ -0,0 +1,8 @@
// Shared constants for PageEditor grid layout
export const GRID_CONSTANTS = {
ITEM_WIDTH: '20rem', // page width
ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap
ITEM_GAP: '1.5rem', // gap between items
OVERSCAN_SMALL: 4, // Overscan for normal documents
OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages)
} as const;

View File

@ -0,0 +1,176 @@
import { useMemo } from 'react';
import { useFileState } from '../../../contexts/FileContext';
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
export interface PageDocumentHook {
document: PDFDocument | null;
isVeryLargeDocument: boolean;
isLoading: boolean;
}
/**
* Hook for managing PDF document state and metadata in PageEditor
* Handles document merging, large document detection, and loading states
*/
export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
const processedFilePages = primaryFileRecord?.processedFile?.pages;
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => {
if (activeFileIds.length === 0) return null;
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryFileRecord) {
console.log('🎬 PageEditor: No primary file record found, showing loading');
return null;
}
const name =
activeFileIds.length === 1
? (primaryFileRecord.name ?? 'document.pdf')
: activeFileIds
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
// Build page insertion map from files with insertion positions
const insertionMap = new Map<string, string[]>(); // insertAfterPageId -> fileIds
const originalFileIds: string[] = [];
activeFileIds.forEach(fileId => {
const record = selectors.getFileRecord(fileId);
if (record?.insertAfterPageId !== undefined) {
if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []);
}
insertionMap.get(record.insertAfterPageId)!.push(fileId);
} else {
originalFileIds.push(fileId);
}
});
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
let totalPageCount = 0;
// Helper function to create pages from a file
const createPagesFromFile = (fileId: string, startPageNumber: number): PDFPage[] => {
const fileRecord = selectors.getFileRecord(fileId);
if (!fileRecord) {
return [];
}
const processedFile = fileRecord.processedFile;
let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) {
// Use fully processed pages with thumbnails
filePages = processedFile.pages.map((page, pageIndex) => ({
id: `${fileId}-${page.pageNumber}`,
pageNumber: startPageNumber + pageIndex,
thumbnail: page.thumbnail || null,
rotation: page.rotation || 0,
selected: false,
splitAfter: page.splitAfter || false,
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
originalFileId: fileId,
}));
} else if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count
filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
id: `${fileId}-${pageIndex + 1}`,
pageNumber: startPageNumber + pageIndex,
originalPageNumber: pageIndex + 1,
originalFileId: fileId,
rotation: 0,
thumbnail: null,
selected: false,
splitAfter: false,
}));
}
return filePages;
};
// Collect all pages from original files (without renumbering yet)
const originalFilePages: PDFPage[] = [];
originalFileIds.forEach(fileId => {
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
originalFilePages.push(...filePages);
});
// Start with all original pages numbered sequentially
pages = originalFilePages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
// Process each insertion by finding the page ID and inserting after it
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId);
if (targetPageIndex === -1) continue;
// Collect all pages to insert
const allNewPages: PDFPage[] = [];
fileIds.forEach(fileId => {
const insertedPages = createPagesFromFile(fileId, 1);
allNewPages.push(...insertedPages);
});
// Insert all new pages after the target page
pages.splice(targetPageIndex + 1, 0, ...allNewPages);
// Renumber all pages after insertion
pages.forEach((page, index) => {
page.pageNumber = index + 1;
});
}
totalPageCount = pages.length;
if (pages.length === 0) {
return null;
}
const mergedDoc: PDFDocument = {
id: activeFileIds.join('-'),
name,
file: primaryFile!,
pages,
totalPages: pages.length,
};
return mergedDoc;
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {
return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false;
}, [mergedPdfDocument?.totalPages]);
// Loading state
const isLoading = globalProcessing && !mergedPdfDocument;
return {
document: mergedPdfDocument,
isVeryLargeDocument,
isLoading
};
}

View File

@ -0,0 +1,95 @@
import { useState, useCallback } from 'react';
export interface PageEditorState {
// Selection state
selectionMode: boolean;
selectedPageIds: string[];
// Animation state
movingPage: number | null;
isAnimating: boolean;
// Split state
splitPositions: Set<number>;
// Export state
exportLoading: boolean;
// Actions
setSelectionMode: (mode: boolean) => void;
setSelectedPageIds: (pages: string[]) => void;
setMovingPage: (pageNumber: number | null) => void;
setIsAnimating: (animating: boolean) => void;
setSplitPositions: (positions: Set<number>) => void;
setExportLoading: (loading: boolean) => void;
// Helper functions
togglePage: (pageId: string) => void;
toggleSelectAll: (allPageIds: string[]) => void;
animateReorder: () => void;
}
/**
* Hook for managing PageEditor UI state
* Handles selection, animation, splits, and export states
*/
export function usePageEditorState(): PageEditorState {
// Selection state
const [selectionMode, setSelectionMode] = useState(false);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
// Animation state
const [movingPage, setMovingPage] = useState<number | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
// Split state - position-based split tracking (replaces page-based splitAfter)
const [splitPositions, setSplitPositions] = useState<Set<number>>(new Set());
// Export state
const [exportLoading, setExportLoading] = useState(false);
// Helper functions
const togglePage = useCallback((pageId: string) => {
setSelectedPageIds(prev =>
prev.includes(pageId)
? prev.filter(id => id !== pageId)
: [...prev, pageId]
);
}, []);
const toggleSelectAll = useCallback((allPageIds: string[]) => {
if (!allPageIds.length) return;
setSelectedPageIds(prev =>
prev.length === allPageIds.length ? [] : allPageIds
);
}, []);
const animateReorder = useCallback(() => {
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 500);
}, []);
return {
// State
selectionMode,
selectedPageIds,
movingPage,
isAnimating,
splitPositions,
exportLoading,
// Setters
setSelectionMode,
setSelectedPageIds,
setMovingPage,
setIsAnimating,
setSplitPositions,
setExportLoading,
// Helpers
togglePage,
toggleSelectAll,
animateReorder,
};
}

View File

@ -26,7 +26,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: '1.5rem' }} />
<AppsIcon sx={{ fontSize: '2rem' }} />
</span>
);

View File

@ -32,9 +32,10 @@ const FileUploadButton = ({
onChange={onChange}
accept={accept}
disabled={disabled}
>
{(props) => (
<Button {...props} variant={variant} fullWidth={fullWidth}>
<Button {...props} variant={variant} fullWidth={fullWidth} color="blue">
{file ? file.name : (placeholder || defaultPlaceholder)}
</Button>
)}

View File

@ -36,7 +36,7 @@ const LandingPage = () => {
};
return (
<Container size="lg" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
<Container size="70rem" p={0} h="102%" className="flex items-center justify-center" style={{ position: 'relative' }}>
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
@ -48,7 +48,7 @@ const LandingPage = () => {
left: '50%',
transform: 'translateX(-50%)',
bottom: 0,
borderRadius: '0.5rem 0.5rem 0 0',
borderRadius: '0.25rem 0.25rem 0 0',
filter: 'var(--drop-shadow-filter)',
backgroundColor: 'var(--landing-paper-bg)',
transition: 'background-color 0.4s ease',
@ -66,7 +66,7 @@ const LandingPage = () => {
style={{
position: 'absolute',
top: 0,
right: ".5rem",
right: 0,
zIndex: 10,
}}
@ -75,15 +75,13 @@ const LandingPage = () => {
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoNoTextDark.svg' : '/branding/StirlingPDFLogoNoTextLight.svg'}
alt="Stirling PDF Logo"
style={{
width: '10rem',
height: 'auto',
pointerEvents: 'none',
marginTop: '-0.5rem'
}}
/>
</div>
<div
className={`min-h-[25vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[360px] border transition-all duration-200 dropzone-inner relative`}
className={`min-h-[45vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[30rem] max-w-[calc(100%-2rem)] border transition-all duration-200 dropzone-inner relative`}
style={{
borderRadius: '0.5rem',
backgroundColor: 'var(--landing-inner-paper-bg)',

View File

@ -22,7 +22,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode } = useToolWorkflow();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@ -68,19 +68,24 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
onClick: () => {
setActiveButton('automate');
handleToolSelect('automate');
// If already on automate tool, reset it directly
if (selectedToolKey === 'automate') {
resetTool('automate');
} else {
handleToolSelect('automate');
}
}
},
{
id: 'files',
name: t("quickAccess.files", "Files"),
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
isRound: true,
size: 'lg',
type: 'modal',
@ -151,7 +156,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
<div className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'xl') : 'lg'}
size={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? (config.size || 'lg') : 'lg'}
variant="subtle"
onClick={() => {
config.onClick();
@ -185,7 +190,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
<div className="spacer" />
{/* Config button at the bottom */}
{buttonConfigs
{/* {buttonConfigs
.filter(config => config.id === 'config')
.map(config => (
<div className="flex flex-col items-center gap-1">
@ -205,14 +210,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
{config.name}
</span>
</div>
))}
))} */}
</div>
</div>
<AppConfigModal
{/* <AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
/> */}
</div>
);
});

View File

@ -45,19 +45,14 @@ export default function RightRail() {
}
if (currentView === 'pageEditor') {
let totalItems = 0;
fileRecords.forEach(rec => {
const pf = rec.processedFile;
if (pf) {
totalItems += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0;
// Use PageEditor's own state
const totalItems = pageEditorFunctions?.totalPages || 0;
const selectedCount = pageEditorFunctions?.selectedPageIds?.length || 0;
return { totalItems, selectedCount };
}
return { totalItems: 0, selectedCount: 0 };
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]);
}, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]);
const { totalItems, selectedCount } = getSelectionState();
@ -70,19 +65,10 @@ export default function RightRail() {
}
if (currentView === 'pageEditor') {
let totalPages = 0;
fileRecords.forEach(rec => {
const pf = rec.processedFile;
if (pf) {
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
if (totalPages > 0) {
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
}
// Use PageEditor's select all function
pageEditorFunctions?.handleSelectAll?.();
}
}, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]);
}, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]);
const handleDeselectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
@ -90,9 +76,10 @@ export default function RightRail() {
return;
}
if (currentView === 'pageEditor') {
setSelectedPages([]);
// Use PageEditor's deselect all function
pageEditorFunctions?.handleDeselectAll?.();
}
}, [currentView, setSelectedFiles, setSelectedPages]);
}, [currentView, setSelectedFiles, pageEditorFunctions]);
const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
@ -151,24 +138,24 @@ export default function RightRail() {
const updatePagesFromCSV = useCallback(() => {
const rawPages = parseCSVInput(csvInput);
// Determine max page count from processed records
const maxPages = fileRecords.reduce((sum, rec) => {
const pf = rec.processedFile;
if (!pf) return sum;
return sum + ((pf.totalPages as number) || (pf.pages?.length || 0));
}, 0);
// Use PageEditor's total pages for validation
const maxPages = pageEditorFunctions?.totalPages || 0;
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
setSelectedPages(normalized);
}, [csvInput, parseCSVInput, fileRecords, setSelectedPages]);
// Use PageEditor's function to set selected pages
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, parseCSVInput, pageEditorFunctions]);
// Sync csvInput with selectedPageNumbers changes
// Sync csvInput with PageEditor's selected pages
useEffect(() => {
const sortedPageNumbers = Array.isArray(selectedPageNumbers)
? [...selectedPageNumbers].sort((a, b) => a - b)
const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPageIds) && pageEditorFunctions.displayDocument
? pageEditorFunctions.selectedPageIds.map(id => {
const page = pageEditorFunctions.displayDocument!.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0).sort((a, b) => a - b)
: [];
const newCsvInput = sortedPageNumbers.join(', ');
setCsvInput(newCsvInput);
}, [selectedPageNumbers]);
}, [pageEditorFunctions?.selectedPageIds]);
// Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => {
@ -278,7 +265,8 @@ export default function RightRail() {
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []}
selectedPageIds={Array.isArray(pageEditorFunctions?.selectedPageIds) ? pageEditorFunctions.selectedPageIds : []}
displayDocument={pageEditorFunctions?.displayDocument}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
</div>
@ -299,8 +287,8 @@ export default function RightRail() {
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }}
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
>
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
@ -311,6 +299,26 @@ export default function RightRail() {
)}
{/* Export Selected Pages - page editor only */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading}
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</div>
</Tooltip>
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
<div>

View File

@ -6,6 +6,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { ModeType, isValidMode } from '../../contexts/NavigationContext';
import { Tooltip } from "./Tooltip";
const viewOptionStyle = {
display: 'inline-flex',
@ -17,8 +18,8 @@ const viewOptionStyle = {
}
// Create view options with icons and loading states
const createViewOptions = (switchingTo: ModeType | null) => [
// Build view options showing text only for current view; others icon-only with tooltip
const createViewOptions = (currentView: ModeType, switchingTo: ModeType | null) => [
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
@ -34,27 +35,35 @@ const createViewOptions = (switchingTo: ModeType | null) => [
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
<span>Page Editor</span>
</div>
<Tooltip content="Page Editor" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />
)}
</div>
</Tooltip>
),
value: "pageEditor",
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
<span>File Manager</span>
</div>
<Tooltip content="Active Files" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />
)}
</div>
</Tooltip>
),
value: "fileEditor",
},
@ -103,11 +112,10 @@ const TopControls = ({
{!isToolSelected && (
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(switchingTo)}
data={createViewOptions(currentView, switchingTo)}
value={currentView}
onChange={handleViewChange}
color="blue"
radius="xl"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
@ -118,13 +126,18 @@ const TopControls = ({
styles={{
root: {
borderRadius: 9999,
maxHeight: '2.6rem',
},
control: {
borderRadius: 9999,
},
indicator: {
borderRadius: 9999,
maxHeight: '2rem',
},
label: {
paddingTop: '0rem',
}
}}
/>
</div>

View File

@ -41,7 +41,7 @@ export default function ToolPanel() {
isRainbowMode ? rainbowStyles.rainbowPaper : ''
}`}
style={{
width: isPanelVisible ? '20rem' : '0',
width: isPanelVisible ? '18.5rem' : '0',
padding: '0'
}}
>

View File

@ -131,7 +131,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 700,
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
@ -141,7 +141,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
}}
onClick={() => scrollTo(quickAccessRef)}
>
<span>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",
@ -156,7 +156,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</span>
</div>
<Box ref={quickAccessRef} w="100%">
<Box ref={quickAccessRef} w="100%" my="sm">
<Stack p="sm" gap="xs">
{quickSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, false)
@ -177,7 +177,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 700,
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
@ -187,7 +187,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
}}
onClick={() => scrollTo(allToolsRef)}
>
<span>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<span
style={{
background: "var(--tool-header-badge-bg)",

View File

@ -16,7 +16,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={watermarkType === 'text' ? 'filled' : 'outline'}
color={watermarkType === 'text' ? 'blue' : 'gray'}
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('text')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
@ -27,7 +27,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
</Button>
<Button
variant={watermarkType === 'image' ? 'filled' : 'outline'}
color={watermarkType === 'image' ? 'blue' : 'gray'}
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('image')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}

View File

@ -6,6 +6,7 @@ import {
Stack,
Group,
TextInput,
Textarea,
Divider,
Modal
} from '@mantine/core';
@ -13,6 +14,7 @@ import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal';
import ToolList from './ToolList';
import IconSelector from './IconSelector';
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
@ -31,6 +33,10 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
const {
automationName,
setAutomationName,
automationDescription,
setAutomationDescription,
automationIcon,
setAutomationIcon,
selectedTools,
addTool,
removeTool,
@ -100,7 +106,8 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
const automationData = {
name: automationName.trim(),
description: '',
description: automationDescription.trim(),
icon: automationIcon,
operations: selectedTools.map(tool => ({
operation: tool.operation,
parameters: tool.parameters || {}
@ -114,7 +121,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
if (mode === AutomationMode.EDIT && existingAutomation) {
// For edit mode, check if name has changed
const nameChanged = automationName.trim() !== existingAutomation.name;
if (nameChanged) {
// Name changed - create new automation
savedAutomation = await automationStorage.saveAutomation(automationData);
@ -144,17 +151,39 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
return (
<div>
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
{t("automate.creation.intro", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
</Text>
<Divider mb="md" />
<Stack gap="md">
{/* Automation Name */}
<TextInput
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
value={automationName}
onChange={(e) => setAutomationName(e.currentTarget.value)}
{/* Automation Name and Icon */}
<Group gap="xs" align="flex-end">
<Stack gap="xs" style={{ flex: 1 }}>
<TextInput
placeholder={t('automate.creation.name.placeholder', 'My Automation')}
value={automationName}
withAsterisk
label={t('automate.creation.name.label', 'Automation Name')}
onChange={(e) => setAutomationName(e.currentTarget.value)}
size="sm"
/>
</Stack>
<IconSelector
value={automationIcon || 'SettingsIcon'}
onChange={setAutomationIcon}
size="sm"
/>
</Group>
{/* Automation Description */}
<Textarea
placeholder={t('automate.creation.description.placeholder', 'Describe what this automation does...')}
value={automationDescription}
label={t('automate.creation.description.label', 'Description')}
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
size="sm"
rows={3}
/>

View File

@ -6,6 +6,7 @@ import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
@ -28,6 +29,8 @@ interface AutomationEntryProps {
onDelete?: () => void;
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<string, ToolRegistryEntry>;
}
export default function AutomationEntry({
@ -40,7 +43,8 @@ export default function AutomationEntry({
showMenu = false,
onEdit,
onDelete,
onCopy
onCopy,
toolRegistry
}: AutomationEntryProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@ -49,9 +53,19 @@ export default function AutomationEntry({
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation]?.name) {
return toolRegistry[operation].name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);
};
// Create tooltip content with description and tool chain
const createTooltipContent = () => {
if (!description) return null;
// Show tooltip if there's a description OR if there are operations to show in the chain
if (!description && operations.length === 0) return null;
const toolChain = operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
@ -68,7 +82,7 @@ export default function AutomationEntry({
whiteSpace: 'nowrap'
}}
>
{t(`${op}.title`, op)}
{getToolDisplayName(op)}
</Text>
{index < operations.length - 1 && (
<Text component="span" size="sm" mx={4}>
@ -80,12 +94,16 @@ export default function AutomationEntry({
return (
<div style={{ minWidth: '400px', width: 'auto' }}>
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
{description}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
{toolChain}
</div>
{description && (
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
{description}
</Text>
)}
{operations.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
{toolChain}
</div>
)}
</div>
);
};
@ -122,7 +140,7 @@ export default function AutomationEntry({
{operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t(`${op}.title`, op)}
{getToolDisplayName(op)}
</Text>
{index < operations.length - 1 && (
@ -221,8 +239,10 @@ export default function AutomationEntry({
</Box>
);
// Only show tooltip if description exists, otherwise return plain content
return description ? (
// Show tooltip if there's a description OR operations to display
const shouldShowTooltip = description || operations.length > 0;
return shouldShowTooltip ? (
<Tooltip
content={createTooltipContent()}
position="right"

View File

@ -6,6 +6,8 @@ import SettingsIcon from "@mui/icons-material/Settings";
import AutomationEntry from "./AutomationEntry";
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
import { iconMap } from './iconMap';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
@ -14,6 +16,7 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
}
export default function AutomationSelection({
@ -22,7 +25,8 @@ export default function AutomationSelection({
onRun,
onEdit,
onDelete,
onCopyFromSuggested
onCopyFromSuggested,
toolRegistry
}: AutomationSelectionProps) {
const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations();
@ -40,20 +44,26 @@ export default function AutomationSelection({
operations={[]}
onClick={onCreateNew}
keepIconColor={true}
toolRegistry={toolRegistry}
/>
{/* Saved Automations */}
{savedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
title={automation.name}
badgeIcon={SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
/>
))}
{savedAutomations.map((automation) => {
const IconComponent = automation.icon ? iconMap[automation.icon as keyof typeof iconMap] : SettingsIcon;
return (
<AutomationEntry
key={automation.id}
title={automation.name}
description={automation.description}
badgeIcon={IconComponent || SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
toolRegistry={toolRegistry}
/>
);
})}
<Divider pb='sm' />
{/* Suggested Automations */}
@ -72,6 +82,7 @@ export default function AutomationSelection({
onClick={() => onRun(automation)}
showMenu={true}
onCopy={() => onCopyFromSuggested(automation)}
toolRegistry={toolRegistry}
/>
))}
</Stack>

View File

@ -0,0 +1,116 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Text, Stack, Button, SimpleGrid, Tooltip, Popover } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { iconMap, iconOptions } from './iconMap';
interface IconSelectorProps {
value?: string;
onChange?: (iconKey: string) => void;
size?: "sm" | "md" | "lg";
}
export default function IconSelector({ value = "SettingsIcon", onChange, size = "sm" }: IconSelectorProps) {
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const selectedIconComponent = iconMap[value as keyof typeof iconMap] || iconMap.SettingsIcon;
const handleIconSelect = (iconKey: string) => {
onChange?.(iconKey);
setIsDropdownOpen(false);
};
const iconSize = size === "sm" ? 16 : size === "md" ? 18 : 20;
return (
<Stack gap="1px">
<Text size="sm" fw={600} style={{ color: "var(--mantine-color-primary)" }}>
{t("automate.creation.icon.label", "Icon")}
</Text>
<Popover
opened={isDropdownOpen}
onClose={() => setIsDropdownOpen(false)}
onDismiss={() => setIsDropdownOpen(false)}
position="bottom-start"
withArrow
trapFocus
>
<Popover.Target>
<Button
variant="outline"
size={size}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
style={{
width: size === "sm" ? "3.75rem" : "4.375rem",
position: "relative",
display: "flex",
justifyContent: "flex-start",
paddingLeft: "0.5rem",
borderColor: "var(--mantine-color-gray-3)",
color: "var(--mantine-color-text)",
}}
>
{React.createElement(selectedIconComponent, { style: { fontSize: iconSize } })}
<KeyboardArrowDownIcon
style={{
fontSize: iconSize * 0.8,
position: "absolute",
right: "0.25rem",
top: "50%",
transform: "translateY(-50%)",
}}
/>
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<SimpleGrid cols={4} spacing="xs">
{iconOptions.map((option) => {
const IconComponent = iconMap[option.value as keyof typeof iconMap];
const isSelected = value === option.value;
return (
<Tooltip key={option.value} label={option.label}>
<Box
onClick={() => handleIconSelect(option.value)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.5rem",
borderRadius: "0.25rem",
cursor: "pointer",
backgroundColor: isSelected ? "var(--mantine-color-gray-1)" : "transparent",
}}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-0)";
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
>
<IconComponent
style={{
fontSize: iconSize,
color: isSelected ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-7)",
}}
/>
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Stack>
</Popover.Dropdown>
</Popover>
</Stack>
);
}

View File

@ -63,22 +63,24 @@ export default function ToolList({
borderBottomWidth: tool.operation && !tool.configured ? "0" : "1px",
}}
>
{/* Delete X in top right */}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => onToolRemove(index)}
title={t("automate.creation.tools.remove", "Remove tool")}
style={{
position: "absolute",
top: "4px",
right: "4px",
zIndex: 1,
color: "var(--mantine-color-gray-6)",
}}
>
<CloseIcon style={{ fontSize: 16 }} />
</ActionIcon>
{/* Delete X in top right - only show for tools after the first 2 */}
{index > 1 && (
<ActionIcon
variant="subtle"
size="xs"
onClick={() => onToolRemove(index)}
title={t("automate.creation.tools.remove", "Remove tool")}
style={{
position: "absolute",
top: "4px",
right: "4px",
zIndex: 1,
color: "var(--mantine-color-gray-6)",
}}
>
<CloseIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
<div style={{ paddingRight: "1.25rem" }}>
{/* Tool Selection Dropdown with inline settings cog */}

View File

@ -0,0 +1,92 @@
import SettingsIcon from '@mui/icons-material/Settings';
import CompressIcon from '@mui/icons-material/Compress';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import CropIcon from '@mui/icons-material/Crop';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import FolderIcon from '@mui/icons-material/Folder';
import CloudIcon from '@mui/icons-material/Cloud';
import StorageIcon from '@mui/icons-material/Storage';
import SearchIcon from '@mui/icons-material/Search';
import DownloadIcon from '@mui/icons-material/Download';
import UploadIcon from '@mui/icons-material/Upload';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import VisibilityIcon from '@mui/icons-material/Visibility';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import WorkIcon from '@mui/icons-material/Work';
import BuildIcon from '@mui/icons-material/Build';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import CheckIcon from '@mui/icons-material/Check';
import SecurityIcon from '@mui/icons-material/Security';
import StarIcon from '@mui/icons-material/Star';
export const iconMap = {
SettingsIcon,
CompressIcon,
SwapHorizIcon,
CleaningServicesIcon,
CropIcon,
TextFieldsIcon,
PictureAsPdfIcon,
EditIcon,
DeleteIcon,
FolderIcon,
CloudIcon,
StorageIcon,
SearchIcon,
DownloadIcon,
UploadIcon,
PlayArrowIcon,
RotateLeftIcon,
RotateRightIcon,
VisibilityIcon,
ContentCutIcon,
ContentCopyIcon,
WorkIcon,
BuildIcon,
AutoAwesomeIcon,
SmartToyIcon,
CheckIcon,
SecurityIcon,
StarIcon
};
export const iconOptions = [
{ value: 'SettingsIcon', label: 'Settings' },
{ value: 'CompressIcon', label: 'Compress' },
{ value: 'SwapHorizIcon', label: 'Convert' },
{ value: 'CleaningServicesIcon', label: 'Clean' },
{ value: 'CropIcon', label: 'Crop' },
{ value: 'TextFieldsIcon', label: 'Text' },
{ value: 'PictureAsPdfIcon', label: 'PDF' },
{ value: 'EditIcon', label: 'Edit' },
{ value: 'DeleteIcon', label: 'Delete' },
{ value: 'FolderIcon', label: 'Folder' },
{ value: 'CloudIcon', label: 'Cloud' },
{ value: 'StorageIcon', label: 'Storage' },
{ value: 'SearchIcon', label: 'Search' },
{ value: 'DownloadIcon', label: 'Download' },
{ value: 'UploadIcon', label: 'Upload' },
{ value: 'PlayArrowIcon', label: 'Play' },
{ value: 'RotateLeftIcon', label: 'Rotate Left' },
{ value: 'RotateRightIcon', label: 'Rotate Right' },
{ value: 'VisibilityIcon', label: 'View' },
{ value: 'ContentCutIcon', label: 'Cut' },
{ value: 'ContentCopyIcon', label: 'Copy' },
{ value: 'WorkIcon', label: 'Work' },
{ value: 'BuildIcon', label: 'Build' },
{ value: 'AutoAwesomeIcon', label: 'Magic' },
{ value: 'SmartToyIcon', label: 'Robot' },
{ value: 'CheckIcon', label: 'Check' },
{ value: 'SecurityIcon', label: 'Security' },
{ value: 'StarIcon', label: 'Star' }
];
export type IconKey = keyof typeof iconMap;

View File

@ -23,7 +23,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'gray'}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'quality')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
@ -34,7 +34,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
</Button>
<Button
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'gray'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'filesize')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}

View File

@ -15,7 +15,6 @@ export interface FileStatusIndicatorProps {
const FileStatusIndicator = ({
selectedFiles = [],
placeholder,
minFiles = 1,
}: FileStatusIndicatorProps) => {
const { t } = useTranslation();
@ -79,7 +78,7 @@ const { t } = useTranslation();
<Text size="sm" c="dimmed">
<Anchor
size="sm"
onClick={openFilesModal}
onClick={() => openFilesModal()}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />
@ -114,7 +113,7 @@ const { t } = useTranslation();
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={openFilesModal}
onClick={() => openFilesModal()}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
>
<FolderIcon style={{ fontSize: '0.875rem' }} />

View File

@ -50,7 +50,7 @@ const renderTooltipTitle = (
sidebarTooltip={true}
>
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={500} size="lg">
<Text fw={400} size="sm">
{title}
</Text>
<LocalIcon icon="gpp-maybe-outline-rounded" width="1.25rem" height="1.25rem" style={{ color: 'var(--icon-files-color)' }} />
@ -60,7 +60,7 @@ const renderTooltipTitle = (
}
return (
<Text fw={500} size="lg">
<Text fw={500} size="sm">
{title}
</Text>
);
@ -96,7 +96,7 @@ const ToolStep = ({
<div>
<div
style={{
padding: '1rem',
padding: '0.5rem',
opacity: isCollapsed ? 0.8 : 1,
color: isCollapsed ? 'var(--mantine-color-dimmed)' : 'inherit',
transition: 'opacity 0.2s ease, color 0.2s ease'
@ -114,7 +114,7 @@ const ToolStep = ({
>
<Flex align="center" gap="sm">
{shouldShowNumber && (
<Text fw={500} size="lg" c="dimmed">
<Text fw={500} size="sm" c="dimmed" mr="0.5rem">
{stepNumber}
</Text>
)}
@ -135,7 +135,7 @@ const ToolStep = ({
</Flex>
{!isCollapsed && (
<Stack gap="md" pl={_noPadding ? 0 : "md"}>
<Stack gap="sm" pl={_noPadding ? 0 : "sm"}>
{helpText && (
<Text size="sm" c="dimmed">
{helpText}
@ -145,7 +145,7 @@ const ToolStep = ({
</Stack>
)}
</div>
<Divider style={{ marginLeft: '1rem', marginRight: '-0.5rem' }} />
<Divider style={{ color: '#E2E8F0', marginLeft: '1rem', marginRight: '-0.5rem' }} />
</div>
);
}

View File

@ -19,7 +19,7 @@ export const renderToolButtons = (
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<Stack gap="xs">
<div>
{subcategory.tools.map(({ id, tool }) => (
<ToolButton
key={id}
@ -29,6 +29,6 @@ export const renderToolButtons = (
onSelect={onSelect}
/>
))}
</Stack>
</div>
</Box>
);

View File

@ -12,8 +12,10 @@ interface ToolButtonProps {
rounded?: boolean;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, rounded = false }) => {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
const isUnavailable = !tool.component && !tool.link;
const handleClick = (id: string) => {
if (isUnavailable) return;
if (tool.link) {
// Open external link in new tab
window.open(tool.link, '_blank', 'noopener,noreferrer');
@ -23,35 +25,30 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
onSelect(id);
};
const tooltipContent = isUnavailable
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
: tool.description;
return (
<Tooltip content={tool.description} position="right" arrow={true} delay={500}>
<Tooltip content={tooltipContent} position="right" arrow={true} delay={500}>
<Button
variant={isSelected ? "filled" : "subtle"}
onClick={()=> handleClick(id)}
size="md"
size="sm"
radius="md"
leftSection={<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}
fullWidth
justify="flex-start"
className="tool-button"
styles={{
root: {
borderRadius: rounded ? 'var(--mantine-radius-lg)' : 0,
color: "var(--tools-text-and-icon-color)",
...(rounded && {
'&:hover': {
borderRadius: 'var(--mantine-radius-lg)',
}
})
}
}}
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%' }}
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
</Button>
</Tooltip>

View File

@ -550,12 +550,14 @@ const Viewer = ({
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<Paper
radius="xl xl 0 0"
shadow="sm"
p={12}
pb={24}
style={{
display: "flex",
alignItems: "center",

View File

@ -73,8 +73,8 @@ function FileContextInner({
}, []);
// File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[]): Promise<File[]> => {
const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch, lifecycleManager);
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise<File[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Persist to IndexedDB if enabled
if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) {
@ -167,7 +167,16 @@ function FileContextInner({
filesRef.current.clear();
dispatch({ type: 'RESET_CONTEXT' });
// Clear IndexedDB if enabled
// Don't clear IndexedDB automatically - only clear in-memory state
// IndexedDB should only be cleared when explicitly requested by user
},
clearAllData: async () => {
// First clear all files from memory
lifecycleManager.cleanupAllFiles();
filesRef.current.clear();
dispatch({ type: 'RESET_CONTEXT' });
// Then clear IndexedDB storage
if (indexedDB && enablePersistence) {
try {
await indexedDB.clearAll();

View File

@ -4,7 +4,7 @@ import { FileMetadata } from '../types/file';
interface FilesModalContextType {
isFilesModalOpen: boolean;
openFilesModal: () => void;
openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void;
closeFilesModal: () => void;
onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void;
@ -19,30 +19,55 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const [insertAfterPage, setInsertAfterPage] = useState<number | undefined>();
const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>();
const openFilesModal = useCallback(() => {
const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => {
setInsertAfterPage(options?.insertAfterPage);
setCustomHandler(() => options?.customHandler);
setIsFilesModalOpen(true);
}, []);
const closeFilesModal = useCallback(() => {
setIsFilesModalOpen(false);
setInsertAfterPage(undefined); // Clear insertion position
setCustomHandler(undefined); // Clear custom handler
onModalClose?.();
}, [onModalClose]);
const handleFileSelect = useCallback((file: File) => {
addToActiveFiles(file);
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler([file], insertAfterPage);
} else {
// Use normal file handling
addToActiveFiles(file);
}
closeFilesModal();
}, [addToActiveFiles, closeFilesModal]);
}, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleFilesSelect = useCallback((files: File[]) => {
addMultipleFiles(files);
if (customHandler) {
// Use custom handler for special cases (like page insertion)
customHandler(files, insertAfterPage);
} else {
// Use normal file handling
addMultipleFiles(files);
}
closeFilesModal();
}, [addMultipleFiles, closeFilesModal]);
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
addStoredFiles(filesWithMetadata);
if (customHandler) {
// Use custom handler for special cases (like page insertion)
const files = filesWithMetadata.map(item => item.file);
customHandler(files, insertAfterPage);
} else {
// Use normal file handling
addStoredFiles(filesWithMetadata);
}
closeFilesModal();
}, [addStoredFiles, closeFilesModal]);
}, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]);
const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback);

View File

@ -86,6 +86,10 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectTool: (toolId: string) => void;
clearToolSelection: () => void;
// Tool Reset Actions
registerToolReset: (toolId: string, resetFunction: () => void) => void;
resetTool: (toolId: string) => void;
// Workflow Actions (compound actions)
handleToolSelect: (toolId: string) => void;
handleBackToTools: () => void;
@ -106,16 +110,19 @@ interface ToolWorkflowProviderProps {
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
// Store reset functions for tools
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
// Navigation actions and state are available since we're inside NavigationProvider
const { actions } = useNavigationActions();
const navigationState = useNavigationState();
// Tool management hook
const {
toolRegistry,
getSelectedTool,
} = useToolManagement();
// Get selected tool from navigation context
const selectedTool = getSelectedTool(navigationState.selectedToolKey);
@ -147,13 +154,29 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
}, []);
// Tool reset methods
const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => {
setToolResetFunctions(prev => ({ ...prev, [toolId]: resetFunction }));
}, []);
const resetTool = useCallback((toolId: string) => {
// Use the current state directly instead of depending on the state in the closure
setToolResetFunctions(current => {
const resetFunction = current[toolId];
if (resetFunction) {
resetFunction();
}
return current; // Return the same state to avoid unnecessary updates
});
}, []); // Empty dependency array makes this stable
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: string) => {
actions.handleToolSelect(toolId);
// Clear search query when selecting a tool
setSearchQuery('');
// Handle view switching logic
if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') {
setLeftPanelView('toolPicker');
@ -212,6 +235,10 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
selectTool: actions.selectTool,
clearToolSelection: actions.clearToolSelection,
// Tool Reset Actions
registerToolReset,
resetTool,
// Workflow Actions
handleToolSelect,
handleBackToTools,
@ -233,6 +260,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery,
actions.selectTool,
actions.clearToolSelection,
registerToolReset,
resetTool,
handleToolSelect,
handleBackToTools,
handleReaderToggle,
@ -251,36 +280,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext);
if (!context) {
// During development hot reload, temporarily return a safe fallback
if (false && process.env.NODE_ENV === 'development') {
console.warn('ToolWorkflowContext temporarily unavailable during hot reload, using fallback');
// Return minimal safe fallback to prevent crashes
return {
sidebarsVisible: true,
leftPanelView: 'toolPicker',
readerMode: false,
previewFile: null,
pageEditorFunctions: null,
searchQuery: '',
selectedToolKey: null,
selectedTool: null,
toolRegistry: {},
filteredTools: [],
isPanelVisible: true,
setSidebarsVisible: () => {},
setLeftPanelView: () => {},
setReaderMode: () => {},
setPreviewFile: () => {},
setPageEditorFunctions: () => {},
setSearchQuery: () => {},
selectTool: () => {},
clearToolSelection: () => {},
handleToolSelect: () => {},
handleBackToTools: () => {},
handleReaderToggle: () => {}
} as ToolWorkflowContextValue;
}
console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');

View File

@ -84,6 +84,9 @@ interface AddFileOptions {
// For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
// Insertion position
insertAfterPageId?: string;
}
/**
@ -164,6 +167,11 @@ export async function addFiles(
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create initial processedFile metadata with page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
@ -201,6 +209,11 @@ export async function addFiles(
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create processedFile with provided metadata
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
@ -271,6 +284,11 @@ export async function addFiles(
}
}
// Store insertion position if provided
if (options.insertAfterPageId !== undefined) {
record.insertAfterPageId = options.insertAfterPageId;
}
// Create processedFile metadata with correct page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);

View File

@ -43,7 +43,27 @@ import ConvertSettings from '../components/tools/convert/ConvertSettings';
import ChangePermissionsSettings from '../components/tools/changePermissions/ChangePermissionsSettings';
import MergeSettings from '../components/tools/merge/MergeSettings';
const showPlaceholderTools = false; // For development purposes. Allows seeing the full list of tools, even if they're unimplemented
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
// Convert tool supported file formats
export const CONVERT_SUPPORTED_FORMATS = [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
];
// Hook to get the translated tool registry
export function useFlatToolRegistry(): ToolRegistry {
@ -397,6 +417,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
maxFiles: -1,
supportedFormats: CONVERT_SUPPORTED_FORMATS,
endpoints: ["handleData"]
},
"auto-rename-pdf-file": {
@ -592,6 +613,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
supportedFormats: CONVERT_SUPPORTED_FORMATS,
endpoints: [
"pdf-to-img",
"img-to-pdf",
@ -608,24 +630,7 @@ export function useFlatToolRegistry(): ToolRegistry {
"pdf-to-pdfa",
"eml-to-pdf"
],
supportedFormats: [
// Microsoft Office
"doc", "docx", "dot", "dotx", "csv", "xls", "xlsx", "xlt", "xltx", "slk", "dif", "ppt", "pptx",
// OpenDocument
"odt", "ott", "ods", "ots", "odp", "otp", "odg", "otg",
// Text formats
"txt", "text", "xml", "rtf", "html", "lwp", "md",
// Images
"bmp", "gif", "jpeg", "jpg", "png", "tif", "tiff", "pbm", "pgm", "ppm", "ras", "xbm", "xpm", "svg", "svm", "wmf", "webp",
// StarOffice
"sda", "sdc", "sdd", "sdw", "stc", "std", "sti", "stw", "sxd", "sxg", "sxi", "sxw",
// Email formats
"eml",
// Archive formats
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
],
operationConfig: convertOperationConfig,
settingsComponent: ConvertSettings
},
@ -677,14 +682,13 @@ export function useFlatToolRegistry(): ToolRegistry {
if (showPlaceholderTools) {
return allTools;
} else {
const filteredTools = Object.keys(allTools)
.filter(key => allTools[key].component !== null || allTools[key].link)
.reduce((obj, key) => {
obj[key] = allTools[key];
return obj;
}, {} as ToolRegistry);
return filteredTools;
}
const filteredTools = Object.keys(allTools)
.filter(key => allTools[key].component !== null || allTools[key].link)
.reduce((obj, key) => {
obj[key] = allTools[key];
return obj;
}, {} as ToolRegistry);
return filteredTools;
}, [t]); // Only re-compute when translations change
}

View File

@ -44,6 +44,6 @@ export function useAutomateOperation() {
endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor
buildFormData: () => new FormData(), // Not used with customProcessor
customProcessor,
filePrefix: AUTOMATION_CONSTANTS.FILE_PREFIX
filePrefix: '' // No prefix needed since automation handles naming internally
});
}

View File

@ -14,6 +14,8 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
const { t } = useTranslation();
const [automationName, setAutomationName] = useState('');
const [automationDescription, setAutomationDescription] = useState('');
const [automationIcon, setAutomationIcon] = useState<string>('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = useCallback((operation: string) => {
@ -33,6 +35,8 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
useEffect(() => {
if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) {
setAutomationName(existingAutomation.name || '');
setAutomationDescription(existingAutomation.description || '');
setAutomationIcon(existingAutomation.icon || '');
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
@ -101,6 +105,10 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
return {
automationName,
setAutomationName,
automationDescription,
setAutomationDescription,
automationIcon,
setAutomationIcon,
selectedTools,
setSelectedTools,
addTool,

View File

@ -45,10 +45,27 @@ export function useSavedAutomations() {
try {
const { automationStorage } = await import('../../../services/automationStorage');
// Map suggested automation icons to MUI icon keys
const getIconKey = (suggestedIcon: {id: string}): string => {
// Check the automation ID or name to determine the appropriate icon
switch (suggestedAutomation.id) {
case 'secure-pdf-ingestion':
case 'secure-workflow':
return 'SecurityIcon'; // Security icon for security workflows
case 'email-preparation':
return 'CompressIcon'; // Compression icon
case 'process-images':
return 'StarIcon'; // Star icon for process images
default:
return 'SettingsIcon'; // Default fallback
}
};
// Convert suggested automation to saved automation format
const savedAutomation = {
name: suggestedAutomation.name,
description: suggestedAutomation.description,
icon: getIconKey(suggestedAutomation.icon),
operations: suggestedAutomation.operations
};

View File

@ -117,7 +117,7 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
{
id: "secure-workflow",
name: t("automation.suggested.secureWorkflow", "Security Workflow"),
description: t("automation.suggested.secureWorkflowDesc", "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access."),
description: t("automation.suggested.secureWorkflowDesc", "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default."),
operations: [
{
operation: "sanitize",

View File

@ -156,10 +156,16 @@ export const useToolOperation = <TParams = void>(
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
// Multi-file responses are typically ZIP files that need extraction
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validFiles);
} else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = validFiles[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
processedFiles = [singleFile];
} else {
// Default: assume ZIP response for multi-file endpoints
processedFiles = await extractZipFiles(response.data);

View File

@ -77,6 +77,7 @@ export function usePDFProcessor() {
pages.push({
id: `${file.name}-page-${i}`,
pageNumber: i,
originalPageNumber: i,
thumbnail: null, // Will be loaded lazily
rotation: 0,
selected: false

View File

@ -11,6 +11,8 @@ interface RainbowThemeHook {
deactivateRainbow: () => void;
}
const allowRainbowMode = false; // Override to allow/disallow fun
export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): RainbowThemeHook {
// Get theme from localStorage or use initial
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
@ -35,11 +37,11 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
// Save theme to localStorage whenever it changes
useEffect(() => {
localStorage.setItem('stirling-theme', themeMode);
// Apply rainbow class to body if in rainbow mode
if (themeMode === 'rainbow') {
document.body.classList.add('rainbow-mode-active');
// Show easter egg notification
showRainbowNotification();
} else {
@ -77,7 +79,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
pointer-events: none;
transition: opacity 0.3s ease;
`;
document.body.appendChild(notification);
// Auto-remove notification after 3 seconds
@ -121,7 +123,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
pointer-events: none;
transition: opacity 0.3s ease;
`;
document.body.appendChild(notification);
// Auto-remove notification after 2 seconds
@ -144,7 +146,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
}
const currentTime = Date.now();
// Simple exit from rainbow mode with single click (after cooldown period)
if (themeMode === 'rainbow') {
setThemeMode('light');
@ -152,7 +154,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
showExitNotification();
return;
}
// Reset counter if too much time has passed (2.5 seconds)
if (currentTime - lastToggleTime.current > 2500) {
toggleCount.current = 1;
@ -162,18 +164,18 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
lastToggleTime.current = currentTime;
// Easter egg: Activate rainbow mode after 10 rapid toggles
if (toggleCount.current >= 10) {
if (allowRainbowMode && toggleCount.current >= 10) {
setThemeMode('rainbow');
console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!');
console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!');
// Disable toggle for 3 seconds
setIsToggleDisabled(true);
setTimeout(() => {
setIsToggleDisabled(false);
console.log('🌈 Theme toggle re-enabled! Click once to exit rainbow mode.');
}, 3000);
// Reset counter
toggleCount.current = 0;
return;
@ -203,4 +205,4 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb
activateRainbow,
deactivateRainbow,
};
}
}

View File

@ -32,7 +32,7 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
icon: CleaningServicesIcon
},
{
id: 'split',
id: 'splitPdf',
title: 'Split',
icon: CropIcon
},

View File

@ -64,7 +64,9 @@ export function useToolSections(filteredTools: [string /* FIX ME: Should be Tool
Object.entries(subs).forEach(([s, tools]) => {
const subcategoryId = s as SubcategoryId;
if (!quick[subcategoryId]) quick[subcategoryId] = [];
quick[subcategoryId].push(...tools);
// Only include ready tools (have a component or external link) in Quick Access
const readyTools = tools.filter(({ tool }) => tool.component !== null || !!tool.link);
quick[subcategoryId].push(...readyTools);
});
}
});

View File

@ -0,0 +1,176 @@
import { PDFDocument, PDFPage } from '../types/pageEditor';
/**
* Service for applying DOM changes to PDF document state
* Reads current DOM state and updates the document accordingly
*/
export class DocumentManipulationService {
/**
* Apply all DOM changes (rotations, splits, reordering) to document state
* 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));
// Convert position-based splits to page-based splits for export
if (splitPositions && splitPositions.size > 0) {
updatedPages = updatedPages.map((page, index) => ({
...page,
splitAfter: splitPositions.has(index)
}));
}
// Create final document with reordered pages and applied changes
const finalDocument = {
...pdfDocument, // Use original document metadata but updated pages
pages: updatedPages // Use reordered pages with applied changes
};
// Check for splits and return multiple documents if needed
if (splitPositions && splitPositions.size > 0) {
return this.createSplitDocuments(finalDocument);
}
return finalDocument;
}
/**
* Check if document has split markers
*/
private hasSplitMarkers(document: PDFDocument): boolean {
return document.pages.some(page => page.splitAfter);
}
/**
* Create multiple documents from split markers
*/
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,
id: `${document.id}_part_${partNumber}`,
name: `${document.name.replace(/\.pdf$/i, '')}_part_${partNumber}.pdf`,
pages: segmentPages,
totalPages: segmentPages.length
});
partNumber++;
}
startIndex = endIndex;
}
console.log(`Created ${documents.length} split documents`);
return documents;
}
/**
* Apply DOM changes for a single page
*/
private applyPageChanges(page: PDFPage): PDFPage {
// 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;
}
const updatedPage = { ...page };
// Apply rotation changes from DOM
updatedPage.rotation = this.getRotationFromDOM(pageElement, page);
return updatedPage;
}
/**
* Read rotation from DOM element
*/
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;
}
console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`);
return originalPage.rotation;
}
/**
* Reset all DOM changes (useful for "discard changes" functionality)
*/
resetDOMToDocumentState(pdfDocument: PDFDocument): void {
console.log('DocumentManipulationService: Resetting DOM to match document state');
pdfDocument.pages.forEach(page => {
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Reset rotation to match document state
img.style.transform = `rotate(${page.rotation}deg)`;
}
}
});
}
/**
* Check if DOM state differs from document state
*/
hasUnsavedChanges(pdfDocument: PDFDocument): boolean {
return pdfDocument.pages.some(page => {
const pageElement = document.querySelector(`[data-page-id="${page.id}"]`);
if (pageElement) {
const domRotation = this.getRotationFromDOM(pageElement, page);
return domRotation !== page.rotation;
}
return false;
});
}
}
// Export singleton instance
export const documentManipulationService = new DocumentManipulationService();

View File

@ -4,20 +4,18 @@ import { PDFDocument, PDFPage } from '../types/pageEditor';
export interface ExportOptions {
selectedOnly?: boolean;
filename?: string;
splitDocuments?: boolean;
appendSuffix?: boolean; // when false, do not append _edited/_selected
}
export class PDFExportService {
/**
* Export PDF document with applied operations
* Export PDF document with applied operations (single file source)
*/
async exportPDF(
pdfDocument: PDFDocument,
selectedPageIds: string[] = [],
options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options;
): Promise<{ blob: Blob; filename: string }> {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
@ -29,17 +27,13 @@ export class PDFExportService {
throw new Error('No pages to export');
}
// Load original PDF once
// Load original PDF and create new document
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false);
if (splitDocuments) {
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
} else {
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix);
return { blob, filename: exportFilename };
}
return { blob, filename: exportFilename };
} catch (error) {
console.error('PDF export error:', error);
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
@ -47,28 +41,85 @@ export class PDFExportService {
}
/**
* Create a single PDF document with all operations applied
* Export PDF document with applied operations (multi-file source)
*/
private async createSingleDocument(
sourceDoc: PDFLibDocument,
async exportPDFMultiFile(
pdfDocument: PDFDocument,
sourceFiles: Map<string, File>,
selectedPageIds: string[] = [],
options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string }> {
const { selectedOnly = false, filename } = options;
try {
// Determine which pages to export
const pagesToExport = selectedOnly && selectedPageIds.length > 0
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
if (pagesToExport.length === 0) {
throw new Error('No pages to export');
}
const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false);
return { blob, filename: exportFilename };
} catch (error) {
console.error('Multi-file PDF export error:', error);
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Create a PDF document from multiple source files
*/
private async createMultiSourceDocument(
sourceFiles: Map<string, File>,
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
// Load all source documents once and cache them
const loadedDocs = new Map<string, PDFLibDocument>();
for (const [fileId, file] of sourceFiles) {
try {
const arrayBuffer = await file.arrayBuffer();
const doc = await PDFLibDocument.load(arrayBuffer);
loadedDocs.set(fileId, doc);
} catch (error) {
console.warn(`Failed to load source file ${fileId}:`, error);
}
}
for (const page of pages) {
// Get the original page from source document
const sourcePageIndex = this.getOriginalSourceIndex(page);
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
// Apply rotation
// Apply rotation if needed
if (page.rotation !== 0) {
copiedPage.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)!;
const sourcePageIndex = page.originalPageNumber - 1;
newDoc.addPage(copiedPage);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// 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));
}
newDoc.addPage(copiedPage);
}
} else {
console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`);
}
}
@ -83,103 +134,60 @@ export class PDFExportService {
}
/**
* Create multiple PDF documents based on split markers
* Create a single PDF document with all operations applied (single source)
*/
private async createSplitDocuments(
private async createSingleDocument(
sourceDoc: PDFLibDocument,
pages: PDFPage[],
baseFilename: string
): Promise<{ blobs: Blob[]; filenames: string[] }> {
const splitPoints: number[] = [];
const blobs: Blob[] = [];
const filenames: string[] = [];
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
// Find split points
pages.forEach((page, index) => {
if (page.splitBefore && index > 0) {
splitPoints.push(index);
}
});
for (const page of pages) {
if (page.isBlankPage || page.originalPageNumber === -1) {
// Create a blank page
const blankPage = newDoc.addPage(PageSizes.A4);
// Add end point
splitPoints.push(pages.length);
let startIndex = 0;
let partNumber = 1;
for (const endIndex of splitPoints) {
const segmentPages = pages.slice(startIndex, endIndex);
if (segmentPages.length > 0) {
const newDoc = await PDFLibDocument.create();
for (const page of segmentPages) {
const sourcePageIndex = this.getOriginalSourceIndex(page);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
newDoc.addPage(copiedPage);
}
// Apply rotation if needed
if (page.rotation !== 0) {
blankPage.setRotation(degrees(page.rotation));
}
} else {
// Get the original page from source document using originalPageNumber
const sourcePageIndex = page.originalPageNumber - 1;
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setTitle(`${baseFilename} - Part ${partNumber}`);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
const pdfBytes = await newDoc.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const filename = this.generateSplitFilename(baseFilename, partNumber);
// Apply rotation
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
blobs.push(blob);
filenames.push(filename);
partNumber++;
}
startIndex = endIndex;
}
return { blobs, filenames };
}
/**
* Derive the original page index from a page's stable id.
* Falls back to the current pageNumber if parsing fails.
*/
private getOriginalSourceIndex(page: PDFPage): number {
const match = page.id.match(/-page-(\d+)$/);
if (match) {
const originalNumber = parseInt(match[1], 10);
if (!Number.isNaN(originalNumber)) {
return originalNumber - 1; // zero-based index for pdf-lib
newDoc.addPage(copiedPage);
}
}
}
// Fallback to the visible page number
return Math.max(0, page.pageNumber - 1);
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setCreationDate(new Date());
newDoc.setModificationDate(new Date());
const pdfBytes = await newDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
/**
* Generate appropriate filename for export
*/
private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string {
const baseName = originalName.replace(/\.pdf$/i, '');
if (!appendSuffix) return `${baseName}.pdf`;
const suffix = selectedOnly ? '_selected' : '_edited';
return `${baseName}${suffix}.pdf`;
return `${baseName}.pdf`;
}
/**
* Generate filename for split documents
*/
private generateSplitFilename(baseName: string, partNumber: number): string {
const cleanBaseName = baseName.replace(/\.pdf$/i, '');
return `${cleanBaseName}_part_${partNumber}.pdf`;
}
/**
* Download a single file
@ -203,7 +211,6 @@ export class PDFExportService {
* Download multiple files as a ZIP
*/
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
// For now, download files wherindividually
blobs.forEach((blob, index) => {
setTimeout(() => {
this.downloadFile(blob, filenames[index]);
@ -248,8 +255,8 @@ export class PDFExportService {
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
const splitCount = pagesToExport.reduce((count, page, index) => {
return count + (page.splitBefore && index > 0 ? 1 : 0);
const splitCount = pagesToExport.reduce((count, page) => {
return count + (page.splitAfter ? 1 : 0);
}, 1); // At least 1 document
// Rough size estimation (very approximate)

View File

@ -12,7 +12,7 @@ class PDFWorkerManager {
private static instance: PDFWorkerManager;
private activeDocuments = new Set<any>();
private workerCount = 0;
private maxWorkers = 3; // Limit concurrent workers
private maxWorkers = 10; // Limit concurrent workers
private isInitialized = false;
private constructor() {
@ -33,7 +33,6 @@ class PDFWorkerManager {
if (!this.isInitialized) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
this.isInitialized = true;
console.log('🏭 PDF.js worker initialized');
}
}
@ -52,7 +51,6 @@ class PDFWorkerManager {
): Promise<any> {
// Wait if we've hit the worker limit
if (this.activeDocuments.size >= this.maxWorkers) {
console.warn(`🏭 PDF Worker limit reached (${this.maxWorkers}), waiting for available worker...`);
await this.waitForAvailableWorker();
}
@ -89,8 +87,6 @@ class PDFWorkerManager {
this.activeDocuments.add(pdf);
this.workerCount++;
console.log(`🏭 PDF document created (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
return pdf;
} catch (error) {
// If document creation fails, make sure to clean up the loading task
@ -98,7 +94,6 @@ class PDFWorkerManager {
try {
loadingTask.destroy();
} catch (destroyError) {
console.warn('🏭 Error destroying failed loading task:', destroyError);
}
}
throw error;
@ -114,10 +109,7 @@ class PDFWorkerManager {
pdf.destroy();
this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1);
console.log(`🏭 PDF document destroyed (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
} catch (error) {
console.warn('🏭 Error destroying PDF document:', error);
// Still remove from tracking even if destroy failed
this.activeDocuments.delete(pdf);
this.workerCount = Math.max(0, this.workerCount - 1);
@ -129,8 +121,6 @@ class PDFWorkerManager {
* Destroy all active PDF documents
*/
destroyAllDocuments(): void {
console.log(`🏭 Destroying all PDF documents (${this.activeDocuments.size} active)`);
const documentsToDestroy = Array.from(this.activeDocuments);
documentsToDestroy.forEach(pdf => {
this.destroyDocument(pdf);
@ -138,8 +128,6 @@ class PDFWorkerManager {
this.activeDocuments.clear();
this.workerCount = 0;
console.log('🏭 All PDF documents destroyed');
}
/**
@ -173,29 +161,23 @@ class PDFWorkerManager {
* Force cleanup of all workers (emergency cleanup)
*/
emergencyCleanup(): void {
console.warn('🏭 Emergency PDF worker cleanup initiated');
// Force destroy all documents
this.activeDocuments.forEach(pdf => {
try {
pdf.destroy();
} catch (error) {
console.warn('🏭 Emergency cleanup - error destroying document:', error);
}
});
this.activeDocuments.clear();
this.workerCount = 0;
console.warn('🏭 Emergency cleanup completed');
}
/**
* Set maximum concurrent workers
*/
setMaxWorkers(max: number): void {
this.maxWorkers = Math.max(1, Math.min(max, 10)); // Between 1-10 workers
console.log(`🏭 Max workers set to ${this.maxWorkers}`);
this.maxWorkers = Math.max(1, Math.min(max, 15)); // Between 1-15 workers for multi-file support
}
}

View File

@ -40,7 +40,7 @@ export class ThumbnailGenerationService {
private pdfDocumentCache = new Map<string, CachedPDFDocument>();
private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached
constructor(private maxWorkers: number = 3) {
constructor(private maxWorkers: number = 10) {
// PDF rendering requires DOM access, so we use optimized main thread processing
}
@ -207,6 +207,9 @@ export class ThumbnailGenerationService {
// Release reference to PDF document (don't destroy - keep in cache)
this.releasePDFDocument(fileId);
this.cleanupCompletedDocument(fileId);
return allResults;
}
@ -289,6 +292,18 @@ export class ThumbnailGenerationService {
}
}
/**
* Clean up a PDF document from cache when thumbnail generation is complete
* This frees up workers faster for better performance
*/
cleanupCompletedDocument(fileId: string): void {
const cached = this.pdfDocumentCache.get(fileId);
if (cached && cached.refCount <= 0) {
pdfWorkerManager.destroyDocument(cached.pdf);
this.pdfDocumentCache.delete(fileId);
}
}
destroy(): void {
this.clearCache();
this.clearPDFCache();

View File

@ -107,9 +107,8 @@ export const mantineTheme = createTheme({
},
},
},
TextInput: {
styles: {
Textarea: {
styles: (theme: any) => ({
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
@ -123,7 +122,43 @@ export const mantineTheme = createTheme({
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
},
}),
},
TextInput: {
styles: (theme: any) => ({
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
color: 'var(--text-primary)',
'&:focus': {
borderColor: 'var(--color-primary-500)',
boxShadow: '0 0 0 1px var(--color-primary-500)',
},
},
label: {
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
}),
},
PasswordInput: {
styles: (theme: any) => ({
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
color: 'var(--text-primary)',
'&:focus': {
borderColor: 'var(--color-primary-500)',
boxShadow: '0 0 0 1px var(--color-primary-500)',
},
},
label: {
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
}),
},
Select: {

View File

@ -1,8 +1,9 @@
import React, { useState } from "react";
import React, { useState, useMemo, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFileContext } from "../contexts/FileContext";
import { useFileSelection } from "../contexts/FileContext";
import { useNavigation } from "../contexts/NavigationContext";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
import { createFilesToolStep } from "../components/tools/shared/FilesToolStep";
@ -21,6 +22,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const { setMode } = useNavigation();
const { registerToolReset } = useToolWorkflow();
const [currentStep, setCurrentStep] = useState<AutomationStep>(AUTOMATION_STEPS.SELECTION);
const [stepData, setStepData] = useState<AutomationStepData>({ step: AUTOMATION_STEPS.SELECTION });
@ -30,6 +32,28 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasResults = automateOperation.files.length > 0 || automateOperation.downloadUrl !== null;
const { savedAutomations, deleteAutomation, refreshAutomations, copyFromSuggested } = useSavedAutomations();
// Use ref to store the latest reset function to avoid closure issues
const resetFunctionRef = React.useRef<() => void>(null);
// Update ref with latest reset function
resetFunctionRef.current = () => {
automateOperation.resetResults();
automateOperation.clearError();
setCurrentStep(AUTOMATION_STEPS.SELECTION);
setStepData({ step: AUTOMATION_STEPS.SELECTION });
};
// Register reset function with the tool workflow context - only once on mount
React.useEffect(() => {
const stableResetFunction = () => {
if (resetFunctionRef.current) {
resetFunctionRef.current();
}
};
registerToolReset('automate', stableResetFunction);
}, [registerToolReset]); // Only depend on registerToolReset which should be stable
const handleStepChange = (data: AutomationStepData) => {
// If navigating away from run step, reset automation results
if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) {
@ -87,6 +111,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onError?.(`Failed to copy automation: ${suggestedAutomation.name}`);
}
}}
toolRegistry={toolRegistry}
/>
);
@ -132,11 +157,25 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
content
});
// Dynamic file placeholder based on supported types
const filesPlaceholder = useMemo(() => {
if (currentStep === AUTOMATION_STEPS.RUN && stepData.automation?.operations?.length) {
const firstOperation = stepData.automation.operations[0];
const toolConfig = toolRegistry[firstOperation.operation];
// Check if the tool has supportedFormats that include non-PDF formats
if (toolConfig?.supportedFormats && toolConfig.supportedFormats.length > 1) {
return t('automate.files.placeholder.multiFormat', 'Select files to process (supports various formats)');
}
}
return t('automate.files.placeholder', 'Select PDF files to process with this automation');
}, [currentStep, stepData.automation, toolRegistry, t]);
// Always create files step to avoid conditional hook calls
const filesStep = createFilesToolStep(createStep, {
selectedFiles,
isCollapsed: hasResults,
placeholder: t('automate.files.placeholder', 'Select files to process with this automation')
placeholder: filesPlaceholder
});
const automationSteps = [

View File

@ -50,9 +50,9 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleSettingsReset = () => {
compressOperation.resetResults();
onPreviewFile?.(null);
actions.setMode("compress");
};
onPreviewFile?.(null); };
const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;

View File

@ -23,9 +23,19 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(splitParams.getEndpointName());
useEffect(() => {
// Only reset results when parameters change, not when files change
splitOperation.resetResults();
onPreviewFile?.(null);
}, [splitParams.parameters, selectedFiles]);
}, [splitParams.parameters]);
useEffect(() => {
// Reset results when selected files change (user selected different files)
if (selectedFiles.length > 0) {
splitOperation.resetResults();
onPreviewFile?.(null);
}
}, [selectedFiles]);
const handleSplit = async () => {
try {
await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
@ -47,11 +57,10 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleSettingsReset = () => {
splitOperation.resetResults();
onPreviewFile?.(null);
actions.setMode("split");
};
};
const hasFiles = selectedFiles.length > 0;
const hasResults = splitOperation.downloadUrl !== null;
const hasResults = splitOperation.files.length > 0 || splitOperation.downloadUrl !== null;
const settingsCollapsed = !hasFiles || hasResults;
return createToolFlow({

View File

@ -11,6 +11,7 @@ export interface AutomationConfig {
id: string;
name: string;
description?: string;
icon?: string;
operations: AutomationOperation[];
createdAt: string;
updatedAt: string;

View File

@ -55,6 +55,7 @@ export interface FileRecord {
blobUrl?: string;
createdAt?: number;
processedFile?: ProcessedFileMetadata;
insertAfterPageId?: string; // Page ID after which this file should be inserted
isPinned?: boolean;
// Note: File object stored in provider ref, not in state
}
@ -216,13 +217,14 @@ export type FileContextAction =
export interface FileContextActions {
// File management - lightweight actions only
addFiles: (files: File[]) => Promise<File[]>;
addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise<File[]>;
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
reorderFiles: (orderedFileIds: FileId[]) => void;
clearAllFiles: () => Promise<void>;
clearAllData: () => Promise<void>;
// File pinning
pinFile: (file: File) => void;

View File

@ -1,10 +1,13 @@
export interface PDFPage {
id: string;
pageNumber: number;
originalPageNumber: number;
thumbnail: string | null;
rotation: number;
selected: boolean;
splitBefore?: boolean;
splitAfter?: boolean;
isBlankPage?: boolean;
originalFileId?: string;
}
export interface PDFDocument {
@ -47,9 +50,20 @@ export interface PageEditorFunctions {
handleRotate: (direction: 'left' | 'right') => void;
handleDelete: () => void;
handleSplit: () => void;
handleSplitAll: () => void;
handlePageBreak: () => void;
handlePageBreakAll: () => void;
handleSelectAll: () => void;
handleDeselectAll: () => void;
handleSetSelectedPages: (pageNumbers: number[]) => void;
showExportPreview: (selectedOnly: boolean) => void;
onExportSelected: () => void;
onExportAll: () => void;
applyChanges: () => void;
exportLoading: boolean;
selectionMode: boolean;
selectedPages: number[];
selectedPageIds: string[];
displayDocument?: PDFDocument;
splitPositions: Set<number>;
totalPages: number;
}

View File

@ -14,6 +14,19 @@ export const executeToolOperation = async (
parameters: any,
files: File[],
toolRegistry: ToolRegistry
): Promise<File[]> => {
return executeToolOperationWithPrefix(operationName, parameters, files, toolRegistry, AUTOMATION_CONSTANTS.FILE_PREFIX);
};
/**
* Execute a tool operation with custom prefix
*/
export const executeToolOperationWithPrefix = async (
operationName: string,
parameters: any,
files: File[],
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
): Promise<File[]> => {
console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length });
@ -51,15 +64,37 @@ export const executeToolOperation = async (
console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`);
// Multi-file responses are typically ZIP files, but may be single files
const result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
let result;
if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = files[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
result = {
success: true,
files: [singleFile],
errors: []
};
} else {
// ZIP response
result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
}
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
console.log(`📁 Processed ${result.files.length} files from response`);
return result.files;
// Apply prefix to files, replacing any existing prefix
const processedFiles = filePrefix
? result.files.map(file => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
})
: result.files;
console.log(`📁 Processed ${processedFiles.length} files from response`);
return processedFiles;
} else {
// Single-file processing - separate API call per file
@ -83,11 +118,12 @@ export const executeToolOperation = async (
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
// Create result file
// Create result file with automation prefix
const resultFile = ResourceManager.createResultFile(
response.data,
file.name,
AUTOMATION_CONSTANTS.FILE_PREFIX
filePrefix
);
resultFiles.push(resultFile);
console.log(`✅ Created result file: ${resultFile.name}`);
@ -123,6 +159,7 @@ export const executeAutomationSequence = async (
}
let currentFiles = [...initialFiles];
const automationPrefix = automation.name ? `${automation.name}_` : 'automated_';
for (let i = 0; i < automation.operations.length; i++) {
const operation = automation.operations[i];
@ -134,11 +171,12 @@ export const executeAutomationSequence = async (
try {
onStepStart?.(i, operation.operation);
const resultFiles = await executeToolOperation(
const resultFiles = await executeToolOperationWithPrefix(
operation.operation,
operation.parameters || {},
currentFiles,
toolRegistry
toolRegistry,
i === automation.operations.length - 1 ? automationPrefix : '' // Only add prefix to final step
);
console.log(`✅ Step ${i + 1} completed: ${resultFiles.length} result files`);

View File

@ -10,7 +10,15 @@
export const getFilenameFromHeaders = (contentDisposition: string = ''): string | null => {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
return match[1].replace(/['"]/g, '');
const filename = match[1].replace(/['"]/g, '');
// Decode URL-encoded characters (e.g., %20 -> space)
try {
return decodeURIComponent(filename);
} catch (error) {
// If decoding fails, return the original filename
return filename;
}
}
return null;
};