mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
564b14e3e2 | ||
|
|
dad9f20879 | ||
|
|
21483c4ec3 | ||
|
|
fe466b3ebf |
@@ -13,9 +13,11 @@
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(sed:*)"
|
||||
"Bash(sed:*)",
|
||||
"Bash(npm run typecheck:*)",
|
||||
"Bash(git checkout:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"defaultMode": "acceptEdits"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'react-app',
|
||||
'react-app/jest'
|
||||
],
|
||||
rules: {
|
||||
// Custom rules to prevent dangerous file.name as ID patterns
|
||||
'no-file-name-as-id': 'error',
|
||||
'prefer-file-with-id': 'warn'
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
rules: {
|
||||
// Prevent file.name being used where FileId is expected
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'MemberExpression[object.name="file"][property.name="name"]',
|
||||
message: 'Avoid using file.name directly. Use FileWithId.fileId or safeGetFileId() instead to prevent ID collisions.'
|
||||
},
|
||||
{
|
||||
selector: 'CallExpression[callee.name="createOperation"] > ArrayExpression > CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[object.name="f"][property.name="name"]',
|
||||
message: 'Dangerous pattern: Using file.name as ID in createOperation. Use FileWithId.fileId instead.'
|
||||
},
|
||||
{
|
||||
selector: 'ArrayExpression[elements.length>0] CallExpression[callee.property.name="map"] > ArrowFunctionExpression > MemberExpression[property.name="name"]',
|
||||
message: 'Potential file.name as ID usage detected. Ensure proper FileId usage instead of file.name.'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
// Custom settings for our file ID validation
|
||||
'file-id-validation': {
|
||||
// Functions that should only accept FileId, not strings
|
||||
'file-id-only-functions': [
|
||||
'recordOperation',
|
||||
'markOperationApplied',
|
||||
'markOperationFailed',
|
||||
'removeFiles',
|
||||
'updateFileRecord',
|
||||
'pinFile',
|
||||
'unpinFile'
|
||||
],
|
||||
// Functions that should accept FileWithId instead of File
|
||||
'file-with-id-functions': [
|
||||
'createOperation',
|
||||
'executeOperation',
|
||||
'isFilePinned'
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
Generated
-65
@@ -32,7 +32,6 @@
|
||||
"jszip": "^3.10.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
@@ -1775,12 +1774,6 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.0.2.tgz",
|
||||
"integrity": "sha512-hWk3rUtJl2crQK0WNmwg13n82hnTwB99BT99/XI5gZSvIlYZ1TPmMZE8H2dhJJ98J/rm9vYJ/UXNzw3RV5HTpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
|
||||
@@ -3820,17 +3813,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.45.1.tgz",
|
||||
"integrity": "sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -4632,12 +4614,6 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.4.8",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz",
|
||||
"integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
@@ -7412,47 +7388,6 @@
|
||||
"postcss": "^8.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.261.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.261.0.tgz",
|
||||
"integrity": "sha512-jyiXqyrCU+VlpbNNVRA6OQYAVut0XZMYNELCZH+XvTd981VqbE4jXn4XCBreo7XCL2gdPgDVxUVOuzNvEuKcmw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "1.0.2",
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.19.3",
|
||||
"web-vitals": "^4.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rrweb/types": "2.0.0-alpha.17",
|
||||
"rrweb-snapshot": "2.0.0-alpha.17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rrweb/types": {
|
||||
"optional": true
|
||||
},
|
||||
"rrweb-snapshot": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js/node_modules/web-vitals": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
||||
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.27.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.1.tgz",
|
||||
"integrity": "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/precinct": {
|
||||
"version": "12.2.0",
|
||||
"resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz",
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"jszip": "^3.10.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.2",
|
||||
@@ -42,6 +41,7 @@
|
||||
"prebuild": "npm run generate-icons",
|
||||
"build": "npx tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate-licenses": "node scripts/generate-licenses.js",
|
||||
"generate-icons": "node scripts/generate-icons.js",
|
||||
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,178 +0,0 @@
|
||||
/* Light theme variables */
|
||||
:root {
|
||||
--cc-bg: #ffffff;
|
||||
--cc-primary-color: #1c1c1c;
|
||||
--cc-secondary-color: #666666;
|
||||
|
||||
--cc-btn-primary-bg: #007BFF;
|
||||
--cc-btn-primary-color: #ffffff;
|
||||
--cc-btn-primary-border-color: #007BFF;
|
||||
--cc-btn-primary-hover-bg: #0056b3;
|
||||
--cc-btn-primary-hover-color: #ffffff;
|
||||
--cc-btn-primary-hover-border-color: #0056b3;
|
||||
|
||||
--cc-btn-secondary-bg: #f1f3f4;
|
||||
--cc-btn-secondary-color: #1c1c1c;
|
||||
--cc-btn-secondary-border-color: #f1f3f4;
|
||||
--cc-btn-secondary-hover-bg: #007BFF;
|
||||
--cc-btn-secondary-hover-color: #ffffff;
|
||||
--cc-btn-secondary-hover-border-color: #007BFF;
|
||||
|
||||
--cc-separator-border-color: #e0e0e0;
|
||||
|
||||
--cc-toggle-on-bg: #007BFF;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #ffffff;
|
||||
--cc-toggle-off-knob-bg: #ffffff;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #ffffff;
|
||||
--cc-toggle-disabled-icon-color: #ffffff;
|
||||
|
||||
--cc-toggle-readonly-bg: #f1f3f4;
|
||||
--cc-toggle-readonly-knob-bg: #79747E;
|
||||
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
|
||||
|
||||
--cc-section-category-border: #e0e0e0;
|
||||
|
||||
--cc-cookie-category-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-block-border: #f1f3f4;
|
||||
--cc-cookie-category-block-hover-bg: #e9eff4;
|
||||
--cc-cookie-category-block-hover-border: #e9eff4;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #f1f3f4;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
|
||||
|
||||
--cc-footer-bg: #ffffff;
|
||||
--cc-footer-color: #1c1c1c;
|
||||
--cc-footer-border-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme variables */
|
||||
.cc--darkmode{
|
||||
--cc-bg: #2d2d2d;
|
||||
--cc-primary-color: #e5e5e5;
|
||||
--cc-secondary-color: #b0b0b0;
|
||||
|
||||
--cc-btn-primary-bg: #4dabf7;
|
||||
--cc-btn-primary-color: #2d2d2d;
|
||||
--cc-btn-primary-border-color: #4dabf7;
|
||||
--cc-btn-primary-hover-bg: #3d3d3d;
|
||||
--cc-btn-primary-hover-color: #e5e5e5;
|
||||
--cc-btn-primary-hover-border-color: #3d3d3d;
|
||||
|
||||
--cc-btn-secondary-bg: #3d3d3d;
|
||||
--cc-btn-secondary-color: #e5e5e5;
|
||||
--cc-btn-secondary-border-color: #3d3d3d;
|
||||
--cc-btn-secondary-hover-bg: #4dabf7;
|
||||
--cc-btn-secondary-hover-color: #2d2d2d;
|
||||
--cc-btn-secondary-hover-border-color: #4dabf7;
|
||||
|
||||
--cc-separator-border-color: #555555;
|
||||
|
||||
--cc-toggle-on-bg: #4dabf7;
|
||||
--cc-toggle-off-bg: #667481;
|
||||
--cc-toggle-on-knob-bg: #2d2d2d;
|
||||
--cc-toggle-off-knob-bg: #2d2d2d;
|
||||
|
||||
--cc-toggle-enabled-icon-color: #2d2d2d;
|
||||
--cc-toggle-disabled-icon-color: #2d2d2d;
|
||||
|
||||
--cc-toggle-readonly-bg: #555555;
|
||||
--cc-toggle-readonly-knob-bg: #8e8e8e;
|
||||
--cc-toggle-readonly-knob-icon-color: #555555;
|
||||
|
||||
--cc-section-category-border: #555555;
|
||||
|
||||
--cc-cookie-category-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-block-border: #3d3d3d;
|
||||
--cc-cookie-category-block-hover-bg: #4d4d4d;
|
||||
--cc-cookie-category-block-hover-border: #4d4d4d;
|
||||
|
||||
--cc-cookie-category-expanded-block-bg: #3d3d3d;
|
||||
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
|
||||
|
||||
--cc-footer-bg: #2d2d2d;
|
||||
--cc-footer-color: #e5e5e5;
|
||||
--cc-footer-border-color: #2d2d2d;
|
||||
}
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
|
||||
}
|
||||
|
||||
.cm__desc{
|
||||
max-width: 70rem !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
flex-direction: row-reverse !important;
|
||||
gap:10px !important;
|
||||
padding-top: 3.4rem !important;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 1400px) {
|
||||
.cm__body{
|
||||
max-width: 90% !important;
|
||||
flex-direction: column !important;
|
||||
align-items: normal !important;
|
||||
}
|
||||
|
||||
.cm__btns{
|
||||
padding-top: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toggle visibility fixes */
|
||||
#cc-main .section__toggle {
|
||||
opacity: 0 !important; /* Keep invisible but functional */
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-circle {
|
||||
display: block !important;
|
||||
position: absolute !important;
|
||||
transition: transform 0.25s ease !important;
|
||||
}
|
||||
|
||||
#cc-main .toggle__icon-on,
|
||||
#cc-main .toggle__icon-off {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: absolute !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Ensure toggles are visible in both themes */
|
||||
#cc-main .toggle__icon {
|
||||
background: var(--cc-toggle-off-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-off-bg) !important;
|
||||
}
|
||||
|
||||
#cc-main .section__toggle:checked ~ .toggle__icon {
|
||||
background: var(--cc-toggle-on-bg) !important;
|
||||
border: 1px solid var(--cc-toggle-on-bg) !important;
|
||||
}
|
||||
|
||||
/* Ensure toggle text is visible */
|
||||
#cc-main .pm__section-title {
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
|
||||
#cc-main .pm__section-desc {
|
||||
color: var(--cc-secondary-color) !important;
|
||||
}
|
||||
|
||||
/* Make sure the modal has proper contrast */
|
||||
#cc-main .pm {
|
||||
background: var(--cc-bg) !important;
|
||||
color: var(--cc-primary-color) !important;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -41,6 +41,9 @@
|
||||
"save": "Save",
|
||||
"saveToBrowser": "Save to Browser",
|
||||
"download": "Download",
|
||||
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
|
||||
"undo": "Undo",
|
||||
"moreOptions": "More Options",
|
||||
"editYourNewFiles": "Edit your new file(s)",
|
||||
"close": "Close",
|
||||
"fileSelected": "Selected: {{filename}}",
|
||||
@@ -50,7 +53,6 @@
|
||||
"title": "Files",
|
||||
"placeholder": "Select a PDF file in the main view to get started",
|
||||
"upload": "Upload",
|
||||
"uploadFiles": "Upload Files",
|
||||
"addFiles": "Add files",
|
||||
"selectFromWorkbench": "Select files from the workbench or "
|
||||
},
|
||||
@@ -2069,7 +2071,7 @@
|
||||
"loading": "Loading...",
|
||||
"or": "or",
|
||||
"dropFileHere": "Drop file here or click to upload",
|
||||
"dropFilesHere": "Drop files here or click the upload button",
|
||||
"dropFilesHere": "Drop files here or click to upload",
|
||||
"pdfFilesOnly": "PDF files only",
|
||||
"supportedFileTypes": "Supported file types",
|
||||
"upload": "Upload",
|
||||
@@ -2382,4 +2384,4 @@
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@ try {
|
||||
}
|
||||
|
||||
// Generate license report using license-checker (more reliable)
|
||||
const licenseReport = execSync('npx license-checker --production --json', {
|
||||
const licenseReport = execSync('npx license-checker --production --json', {
|
||||
encoding: 'utf8',
|
||||
cwd: path.dirname(PACKAGE_JSON)
|
||||
});
|
||||
|
||||
|
||||
let licenseData;
|
||||
try {
|
||||
licenseData = JSON.parse(licenseReport);
|
||||
@@ -43,16 +43,16 @@ try {
|
||||
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
if (!licenseData || typeof licenseData !== 'object') {
|
||||
console.error('❌ Invalid license data structure');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
// Convert license-checker format to array
|
||||
const licenseArray = Object.entries(licenseData).map(([key, value]) => {
|
||||
let name, version;
|
||||
|
||||
|
||||
// Handle scoped packages like @mantine/core@1.0.0
|
||||
if (key.startsWith('@')) {
|
||||
const parts = key.split('@');
|
||||
@@ -64,30 +64,30 @@ try {
|
||||
name = key.substring(0, lastAtIndex);
|
||||
version = key.substring(lastAtIndex + 1);
|
||||
}
|
||||
|
||||
|
||||
// Normalize license types for edge cases
|
||||
let licenseType = value.licenses;
|
||||
|
||||
|
||||
// Handle missing or null licenses
|
||||
if (!licenseType || licenseType === null || licenseType === undefined) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
// Handle empty string licenses
|
||||
if (licenseType === '') {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
// Handle array licenses (rare but possible)
|
||||
if (Array.isArray(licenseType)) {
|
||||
licenseType = licenseType.join(' AND ');
|
||||
}
|
||||
|
||||
|
||||
// Handle object licenses (fallback)
|
||||
if (typeof licenseType === 'object' && licenseType !== null) {
|
||||
licenseType = 'Unknown';
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
name: name,
|
||||
version: version || value.version || 'unknown',
|
||||
@@ -97,13 +97,13 @@ try {
|
||||
link: value.licenseUrl
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// Transform to match Java backend format
|
||||
const transformedData = {
|
||||
dependencies: licenseArray.map(dep => {
|
||||
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
||||
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
||||
|
||||
|
||||
return {
|
||||
moduleName: dep.name,
|
||||
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
@@ -113,29 +113,29 @@ try {
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
// Log summary of license types found
|
||||
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
||||
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown');
|
||||
acc[license] = (acc[license] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
|
||||
console.log('📊 License types found:');
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
console.log(` ${license}: ${count} packages`);
|
||||
});
|
||||
|
||||
|
||||
// Log any complex or unusual license formats for debugging
|
||||
const complexLicenses = licenseArray.filter(dep =>
|
||||
const complexLicenses = licenseArray.filter(dep =>
|
||||
dep.licenseType && (
|
||||
dep.licenseType.includes('AND') ||
|
||||
dep.licenseType.includes('OR') ||
|
||||
dep.licenseType.includes('AND') ||
|
||||
dep.licenseType.includes('OR') ||
|
||||
dep.licenseType === 'Unknown' ||
|
||||
dep.licenseType.includes('SEE LICENSE')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
if (complexLicenses.length > 0) {
|
||||
console.log('\n🔍 Complex/Edge case licenses detected:');
|
||||
complexLicenses.forEach(dep => {
|
||||
@@ -150,7 +150,7 @@ try {
|
||||
problematicLicenses.forEach(warning => {
|
||||
console.log(` ${warning.message}`);
|
||||
});
|
||||
|
||||
|
||||
// Write license warnings to a separate file for CI/CD
|
||||
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
|
||||
fs.writeFileSync(warningsFile, JSON.stringify({
|
||||
@@ -164,11 +164,11 @@ try {
|
||||
|
||||
// Write to file
|
||||
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
|
||||
|
||||
|
||||
console.log(`✅ License report generated successfully!`);
|
||||
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
|
||||
console.log(`💾 Saved to: ${OUTPUT_FILE}`);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating license report:', error.message);
|
||||
process.exit(1);
|
||||
@@ -179,10 +179,9 @@ try {
|
||||
*/
|
||||
function getLicenseUrl(licenseType) {
|
||||
if (!licenseType || licenseType === 'Unknown') return '';
|
||||
|
||||
|
||||
const licenseUrls = {
|
||||
'MIT': 'https://opensource.org/licenses/MIT',
|
||||
'MIT*': 'https://opensource.org/licenses/MIT',
|
||||
'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
'Apache License 2.0': 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
'BSD-3-Clause': 'https://opensource.org/licenses/BSD-3-Clause',
|
||||
@@ -207,12 +206,12 @@ function getLicenseUrl(licenseType) {
|
||||
'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/',
|
||||
'UNLICENSED': ''
|
||||
};
|
||||
|
||||
|
||||
// Try exact match first
|
||||
if (licenseUrls[licenseType]) {
|
||||
return licenseUrls[licenseType];
|
||||
}
|
||||
|
||||
|
||||
// Try case-insensitive match
|
||||
const lowerType = licenseType.toLowerCase();
|
||||
for (const [key, url] of Object.entries(licenseUrls)) {
|
||||
@@ -220,7 +219,7 @@ function getLicenseUrl(licenseType) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (licenseType.includes('AND') || licenseType.includes('OR')) {
|
||||
// Extract the first license from compound expressions for URL
|
||||
@@ -229,7 +228,7 @@ function getLicenseUrl(licenseType) {
|
||||
return licenseUrls[match[1]];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For non-standard licenses, return empty string (will use package link if available)
|
||||
return '';
|
||||
}
|
||||
@@ -239,7 +238,7 @@ function getLicenseUrl(licenseType) {
|
||||
*/
|
||||
function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
const warnings = [];
|
||||
|
||||
|
||||
// Define problematic license patterns
|
||||
const problematicLicenses = {
|
||||
// Copyleft licenses
|
||||
@@ -249,7 +248,7 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
'LGPL-3.0': 'Weak copyleft license - may require source disclosure for modifications',
|
||||
'AGPL-3.0': 'Network copyleft license - requires source disclosure for network use',
|
||||
'AGPL-1.0': 'Network copyleft license - requires source disclosure for network use',
|
||||
|
||||
|
||||
// Other potentially problematic licenses
|
||||
'WTFPL': 'Potentially problematic license - legal uncertainty',
|
||||
'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license',
|
||||
@@ -268,47 +267,47 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
'UNLICENSED': 'No license specified - usage rights unclear',
|
||||
'Unknown': 'License not detected - manual review required'
|
||||
};
|
||||
|
||||
|
||||
// Known good licenses (no warnings needed)
|
||||
const goodLicenses = new Set([
|
||||
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
||||
'MIT', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
|
||||
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
|
||||
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
|
||||
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE'
|
||||
]);
|
||||
|
||||
|
||||
// Helper function to normalize license names for comparison
|
||||
function normalizeLicense(license) {
|
||||
return license
|
||||
.replace(/-or-later$/, '') // Remove -or-later suffix
|
||||
.replace(/\+$/, '') // Remove + suffix
|
||||
.replace(/\+$/, '') // Remove + suffix
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
||||
// Check each license type
|
||||
Object.entries(licenseSummary).forEach(([license, count]) => {
|
||||
// Skip known good licenses
|
||||
if (goodLicenses.has(license)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if this license only affects our own packages
|
||||
const affectedPackages = licenseArray.filter(dep => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType;
|
||||
return depLicense === license;
|
||||
});
|
||||
|
||||
const isOnlyOurPackages = affectedPackages.every(dep =>
|
||||
dep.name === 'frontend' ||
|
||||
|
||||
const isOnlyOurPackages = affectedPackages.every(dep =>
|
||||
dep.name === 'frontend' ||
|
||||
dep.name.toLowerCase().includes('stirling-pdf') ||
|
||||
dep.name.toLowerCase().includes('stirling_pdf') ||
|
||||
dep.name.toLowerCase().includes('stirlingpdf')
|
||||
);
|
||||
|
||||
|
||||
if (isOnlyOurPackages && (license === 'UNLICENSED' || license.startsWith('SEE LICENSE IN'))) {
|
||||
return; // Skip warnings for our own Stirling-PDF packages
|
||||
}
|
||||
|
||||
|
||||
// Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
|
||||
if (license.includes('AND') || license.includes('OR')) {
|
||||
// For OR licenses, check if there's at least one acceptable license option
|
||||
@@ -318,23 +317,23 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
.replace(/[()]/g, '') // Remove parentheses
|
||||
.split(' OR ')
|
||||
.map(component => component.trim());
|
||||
|
||||
|
||||
// Check if any component is in the goodLicenses set (with normalization)
|
||||
const hasGoodLicense = orComponents.some(component => {
|
||||
const normalized = normalizeLicense(component);
|
||||
return goodLicenses.has(component) || goodLicenses.has(normalized);
|
||||
});
|
||||
|
||||
|
||||
if (hasGoodLicense) {
|
||||
return; // Skip warning - can use the good license option
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// For AND licenses or OR licenses with no good options, check for problematic components
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic =>
|
||||
license.includes(problematic)
|
||||
);
|
||||
|
||||
|
||||
if (hasProblematicComponent) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter(dep => {
|
||||
@@ -346,12 +345,12 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
|
||||
const licenseType = license.includes('AND') ? 'AND' : 'OR';
|
||||
const reason = licenseType === 'AND'
|
||||
const reason = licenseType === 'AND'
|
||||
? 'Compound license with AND requirement - all components must be compatible'
|
||||
: 'Compound license with potentially problematic components and no good fallback options';
|
||||
|
||||
|
||||
warnings.push({
|
||||
message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`,
|
||||
licenseType: license,
|
||||
@@ -363,7 +362,7 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check for exact matches with problematic licenses
|
||||
if (problematicLicenses[license]) {
|
||||
const affectedPackages = licenseArray
|
||||
@@ -376,10 +375,10 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
|
||||
const packageList = affectedPackages.map(pkg => pkg.name).slice(0, 5).join(', ') + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : '');
|
||||
const licenseUrl = getLicenseUrl(license) || 'https://opensource.org/licenses';
|
||||
|
||||
|
||||
warnings.push({
|
||||
message: `⚠️ This PR contains ${count} package${count > 1 ? 's' : ''} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
|
||||
licenseType: license,
|
||||
@@ -400,7 +399,7 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`
|
||||
}));
|
||||
|
||||
|
||||
warnings.push({
|
||||
message: `❓ This PR contains ${count} package${count > 1 ? 's' : ''} with unknown license type "${license}" - manual review required`,
|
||||
licenseType: license,
|
||||
@@ -411,6 +410,6 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,12 @@ import HomePage from "./pages/HomePage";
|
||||
|
||||
// Import global styles
|
||||
import "./styles/tailwind.css";
|
||||
import "./styles/cookieconsent.css";
|
||||
import "./index.css";
|
||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||
|
||||
// Import file ID safety validators (development only)
|
||||
import "./utils/fileIdSafety";
|
||||
|
||||
// Loading component for i18next suspense
|
||||
const LoadingFallback = () => (
|
||||
<div
|
||||
@@ -41,7 +43,7 @@ export default function App() {
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
import { FileId } from '../../types/fileContext';
|
||||
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { FileOperation } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
@@ -16,7 +16,6 @@ import styles from './FileEditor.module.css';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -47,17 +46,15 @@ const FileEditor = ({
|
||||
// Use optimized FileContext hooks
|
||||
const { state, selectors } = useFileState();
|
||||
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
||||
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const isProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get the real context actions
|
||||
const { actions } = useFileActions();
|
||||
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
|
||||
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
@@ -87,9 +84,9 @@ const FileEditor = ({
|
||||
});
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
|
||||
// Create refs for frequently changing values to stabilize callbacks
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
const contextSelectedIdsRef = useRef<string[]>([]);
|
||||
contextSelectedIdsRef.current = contextSelectedIds;
|
||||
|
||||
// Use activeFileRecords directly - no conversion needed
|
||||
@@ -99,7 +96,7 @@ const FileEditor = ({
|
||||
const recordToFileItem = useCallback((record: any) => {
|
||||
const file = selectors.getFile(record.id);
|
||||
if (!file) return null;
|
||||
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: file.name,
|
||||
@@ -160,27 +157,7 @@ const FileEditor = ({
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
// Record ZIP extraction operation
|
||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'convert',
|
||||
timestamp: Date.now(),
|
||||
fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */,
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
extractionType: 'zip',
|
||||
extractedCount: extractionResult.extractedCount,
|
||||
totalFiles: extractionResult.totalFiles
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
@@ -213,25 +190,6 @@ const FileEditor = ({
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Record upload operations for PDF files
|
||||
for (const file of allExtractedFiles) {
|
||||
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'upload',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [file.name as FileId /* This doesn't seem right */],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
uploadMethod: 'drag-drop'
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
@@ -240,7 +198,7 @@ const FileEditor = ({
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
|
||||
// Reset extraction progress on error
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
@@ -264,21 +222,21 @@ const FileEditor = ({
|
||||
// Remove all files from context but keep in storage
|
||||
const allFileIds = activeFileRecords.map(record => record.id);
|
||||
removeFiles(allFileIds, false); // false = keep in storage
|
||||
|
||||
|
||||
// Clear selections
|
||||
setSelectedFiles([]);
|
||||
}, [activeFileRecords, removeFiles, setSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: FileId) => {
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||
|
||||
|
||||
const targetRecord = activeFileRecords.find(r => r.id === fileId);
|
||||
if (!targetRecord) return;
|
||||
|
||||
const contextFileId = fileId; // No need to create a new ID
|
||||
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: FileId[];
|
||||
let newSelection: string[];
|
||||
|
||||
if (isSelected) {
|
||||
// Remove file from selection
|
||||
@@ -287,7 +245,7 @@ const FileEditor = ({
|
||||
// Add file to selection
|
||||
// In tool mode, typically allow multiple files unless specified otherwise
|
||||
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
|
||||
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
@@ -301,7 +259,7 @@ const FileEditor = ({
|
||||
}
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
setSelectedFiles(newSelection.map(id => id as FileId));
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
@@ -315,30 +273,30 @@ const FileEditor = ({
|
||||
}, [setSelectedFiles]);
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
|
||||
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => {
|
||||
const currentIds = activeFileRecords.map(r => r.id);
|
||||
|
||||
|
||||
// Find indices
|
||||
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||
const targetIndex = currentIds.findIndex(id => id === targetFileId);
|
||||
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
console.warn('Could not find source or target file for reordering');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle multi-file selection reordering
|
||||
const filesToMove = selectedFileIds.length > 1
|
||||
? selectedFileIds.filter(id => currentIds.includes(id))
|
||||
const filesToMove = selectedFileIds.length > 1
|
||||
? selectedFileIds.filter(id => currentIds.includes(id as any))
|
||||
: [sourceFileId];
|
||||
|
||||
// Create new order
|
||||
const newOrder = [...currentIds];
|
||||
|
||||
|
||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
|
||||
.sort((a, b) => b - a); // Sort descending
|
||||
|
||||
|
||||
sourceIndices.forEach(index => {
|
||||
newOrder.splice(index, 1);
|
||||
});
|
||||
@@ -360,7 +318,7 @@ const FileEditor = ({
|
||||
}
|
||||
|
||||
// Insert files at the calculated position
|
||||
newOrder.splice(insertIndex, 0, ...filesToMove);
|
||||
newOrder.splice(insertIndex, 0, ...filesToMove.map(id => id as any));
|
||||
|
||||
// Update file order
|
||||
reorderFiles(newOrder);
|
||||
@@ -373,50 +331,30 @@ const FileEditor = ({
|
||||
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
|
||||
if (record && file) {
|
||||
// Record close operation
|
||||
const fileName = file.name;
|
||||
const contextFileId = record.id;
|
||||
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'remove',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [fileName as FileId /* FIX ME: This doesn't seem right */],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: fileName,
|
||||
fileSize: record.size,
|
||||
parameters: {
|
||||
action: 'close',
|
||||
reason: 'user_request'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
removeFiles([contextFileId], false);
|
||||
removeFiles([record.id], false);
|
||||
|
||||
// Remove from context selections
|
||||
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||
const currentSelected = selectedFileIds.filter(id => id !== record.id);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const record = activeFileRecords.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
setSelectedFiles([fileId]);
|
||||
setSelectedFiles([fileId as FileId]);
|
||||
navActions.setWorkbench('viewer');
|
||||
}
|
||||
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: FileId) => {
|
||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
@@ -427,14 +365,14 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeFileRecords, selectors, onMergeFiles]);
|
||||
|
||||
const handleSplitFile = useCallback((fileId: FileId) => {
|
||||
const file = selectors.getFile(fileId);
|
||||
const handleSplitFile = useCallback((fileId: string) => {
|
||||
const file = selectors.getFile(fileId as FileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file);
|
||||
}
|
||||
}, [selectors, onOpenPageEditor]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
@@ -454,6 +392,7 @@ const FileEditor = ({
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{
|
||||
height: '100vh',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
backgroundColor: 'transparent'
|
||||
@@ -461,7 +400,7 @@ const FileEditor = ({
|
||||
activateOnClick={false}
|
||||
activateOnDrag={true}
|
||||
>
|
||||
<Box pos="relative" style={{ overflow: 'auto' }}>
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
@@ -513,11 +452,11 @@ const FileEditor = ({
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
rowGap: '1.5rem',
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
padding: '1rem',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
@@ -525,7 +464,7 @@ const FileEditor = ({
|
||||
{activeFileRecords.map((record, index) => {
|
||||
const fileItem = recordToFileItem(record);
|
||||
if (!fileItem) return null;
|
||||
|
||||
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
@@ -562,7 +501,7 @@ const FileEditor = ({
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
|
||||
@@ -11,10 +11,9 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string | null;
|
||||
@@ -26,14 +25,14 @@ interface FileEditorThumbnailProps {
|
||||
file: FileItem;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: FileId[];
|
||||
selectedFiles: string[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onToggleFile: (fileId: string) => void;
|
||||
onDeleteFile: (fileId: string) => void;
|
||||
onViewFile: (fileId: string) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile?: (fileId: FileId) => void;
|
||||
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
||||
onDownloadFile?: (fileId: string) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@@ -61,8 +60,8 @@ const FileEditorThumbnail = ({
|
||||
|
||||
// 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]);
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
@@ -162,8 +161,8 @@ const FileEditorThumbnail = ({
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
const sourceFileId = sourceData.fileId as string;
|
||||
const selectedFileIds = sourceData.selectedFiles as string[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
@@ -333,7 +332,7 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
|
||||
{/* Title + meta line */}
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
textAlign: 'center',
|
||||
@@ -405,4 +404,4 @@ const FileEditorThumbnail = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(FileEditorThumbnail);
|
||||
export default React.memo(FileEditorThumbnail);
|
||||
@@ -12,12 +12,11 @@ import {
|
||||
Divider
|
||||
} from '@mantine/core';
|
||||
// FileContext no longer needed - these were stub functions anyway
|
||||
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
|
||||
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
|
||||
import { PageOperation } from '../../types/pageEditor';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FileOperationHistoryProps {
|
||||
fileId: FileId;
|
||||
fileId: string;
|
||||
showOnlyApplied?: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
@@ -28,8 +27,8 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
|
||||
maxHeight = 400
|
||||
}) => {
|
||||
// These were stub functions in the old context - replace with empty stubs
|
||||
const getFileHistory = (fileId: FileId) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
|
||||
const getAppliedOperations = (fileId: FileId) => [];
|
||||
const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
|
||||
const getAppliedOperations = (fileId: string) => [];
|
||||
|
||||
const history = getFileHistory(fileId);
|
||||
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
.workbench-scrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar {
|
||||
width: 0.375rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
border-radius: 0.1875rem;
|
||||
}
|
||||
|
||||
.workbench-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
@@ -4,18 +4,16 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileActions } from '../../contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { useToolManagement } from '../../hooks/useToolManagement';
|
||||
import './Workbench.css';
|
||||
|
||||
import TopControls from '../shared/TopControls';
|
||||
import FileEditor from '../fileEditor/FileEditor';
|
||||
import PageEditor from '../pageEditor/PageEditor';
|
||||
import PageEditorControls from '../pageEditor/PageEditorControls';
|
||||
import Viewer from '../viewer/Viewer';
|
||||
import ToolRenderer from '../tools/ToolRenderer';
|
||||
import LandingPage from '../shared/LandingPage';
|
||||
import Footer from '../shared/Footer';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@@ -24,9 +22,10 @@ export default function Workbench() {
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { state } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions } = useFileActions();
|
||||
const { currentMode: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const setCurrentView = navActions.setMode;
|
||||
const activeFiles = state.files.ids;
|
||||
const {
|
||||
previewFile,
|
||||
@@ -37,14 +36,7 @@ export default function Workbench() {
|
||||
setSidebarsVisible
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
|
||||
// Get navigation state - this is the source of truth
|
||||
const { selectedTool: selectedToolId } = useNavigationState();
|
||||
|
||||
// Get tool registry to look up selected tool
|
||||
const { toolRegistry } = useToolManagement();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { selectedToolKey, selectedTool, handleToolSelect } = useToolWorkflow();
|
||||
const { addToActiveFiles } = useFileHandler();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
@@ -77,11 +69,11 @@ export default function Workbench() {
|
||||
case "fileEditor":
|
||||
return (
|
||||
<FileEditor
|
||||
toolMode={!!selectedToolId}
|
||||
toolMode={!!selectedToolKey}
|
||||
showUpload={true}
|
||||
showBulkActions={!selectedToolId}
|
||||
showBulkActions={!selectedToolKey}
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
{...(!selectedToolId && {
|
||||
{...(!selectedToolKey && {
|
||||
onOpenPageEditor: (file) => {
|
||||
setCurrentView("pageEditor");
|
||||
},
|
||||
@@ -135,6 +127,14 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
default:
|
||||
// Check if it's a tool view
|
||||
if (selectedToolKey && selectedTool) {
|
||||
return (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LandingPage/>
|
||||
);
|
||||
@@ -143,7 +143,7 @@ export default function Workbench() {
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex-1 h-full min-w-80 relative flex flex-col"
|
||||
className="flex-1 h-screen min-w-80 relative flex flex-col"
|
||||
style={
|
||||
isRainbowMode
|
||||
? {} // No background color in rainbow mode
|
||||
@@ -154,12 +154,12 @@ export default function Workbench() {
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
selectedToolKey={selectedToolId}
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
className="flex-1 min-h-0 relative z-10"
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
marginTop: '1rem',
|
||||
@@ -167,8 +167,6 @@ export default function Workbench() {
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
|
||||
<Footer analyticsEnabled />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,9 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d
|
||||
|
||||
import styles from './PageEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string | null;
|
||||
@@ -28,12 +27,12 @@ interface FileThumbnailProps {
|
||||
totalFiles: number;
|
||||
selectedFiles: string[];
|
||||
selectionMode: boolean;
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onToggleFile: (fileId: string) => void;
|
||||
onDeleteFile: (fileId: string) => void;
|
||||
onViewFile: (fileId: string) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile?: (fileId: FileId) => void;
|
||||
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
||||
onDownloadFile?: (fileId: string) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@@ -61,8 +60,8 @@ const FileThumbnail = ({
|
||||
|
||||
// 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]);
|
||||
return activeFiles.find(f => f.fileId === file.id);
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
@@ -162,8 +161,8 @@ const FileThumbnail = ({
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as FileId;
|
||||
const selectedFileIds = sourceData.selectedFiles as FileId[];
|
||||
const sourceFileId = sourceData.fileId as string;
|
||||
const selectedFileIds = sourceData.selectedFiles as string[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
|
||||
import { ModeType } from "../../contexts/NavigationContext";
|
||||
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
|
||||
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
@@ -16,7 +17,6 @@ import PageThumbnail from './PageThumbnail';
|
||||
import DragDropGrid from './DragDropGrid';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import NavigationWarningModal from '../shared/NavigationWarningModal';
|
||||
import { FileId } from "../../types/file";
|
||||
|
||||
import {
|
||||
DOMCommand,
|
||||
@@ -83,7 +83,7 @@ const PageEditor = ({
|
||||
|
||||
// Grid container ref for positioning split indicators
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
// State to trigger re-renders when container size changes
|
||||
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 });
|
||||
|
||||
@@ -128,7 +128,7 @@ const PageEditor = ({
|
||||
|
||||
// Interface functions for parent component
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
|
||||
|
||||
// Utility functions to convert between page IDs and page numbers
|
||||
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
|
||||
if (!displayDocument) return [];
|
||||
@@ -137,7 +137,7 @@ const PageEditor = ({
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0);
|
||||
}, [displayDocument]);
|
||||
|
||||
|
||||
const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => {
|
||||
if (!displayDocument) return [];
|
||||
return pageNumbers.map(num => {
|
||||
@@ -145,10 +145,10 @@ const PageEditor = ({
|
||||
return page?.id || '';
|
||||
}).filter(id => id !== '');
|
||||
}, [displayDocument]);
|
||||
|
||||
|
||||
// Convert selectedPageIds to numbers for components that still need numbers
|
||||
const selectedPageNumbers = useMemo(() =>
|
||||
getPageNumbersFromIds(selectedPageIds),
|
||||
const selectedPageNumbers = useMemo(() =>
|
||||
getPageNumbersFromIds(selectedPageIds),
|
||||
[selectedPageIds, getPageNumbersFromIds]
|
||||
);
|
||||
|
||||
@@ -173,8 +173,7 @@ const PageEditor = ({
|
||||
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
|
||||
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
undoManagerRef.current.executeCommand(bulkRotateCommand);
|
||||
}
|
||||
}), []);
|
||||
|
||||
@@ -183,8 +182,7 @@ const PageEditor = ({
|
||||
if (!displayDocument) return;
|
||||
|
||||
const pagesToDelete = pageIds.map(pageId => {
|
||||
|
||||
const page = displayDocument.pages.find(p => p.id === pageId);
|
||||
const page = displayDocument.pages.find(p => p.id === pageId);
|
||||
return page?.pageNumber || 0;
|
||||
}).filter(num => num > 0);
|
||||
|
||||
@@ -215,7 +213,7 @@ const PageEditor = ({
|
||||
);
|
||||
undoManagerRef.current.executeCommand(splitCommand);
|
||||
}
|
||||
}), [splitPositions]);
|
||||
}), [splitPositions]);
|
||||
|
||||
// Command executor for PageThumbnail
|
||||
const executeCommand = useCallback((command: any) => {
|
||||
@@ -236,7 +234,7 @@ const PageEditor = ({
|
||||
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
const rotation = direction === 'left' ? -90 : 90;
|
||||
|
||||
|
||||
handleRotatePages(selectedPageIds, rotation);
|
||||
}, [displayDocument, selectedPageIds, handleRotatePages]);
|
||||
|
||||
@@ -298,14 +296,14 @@ const PageEditor = ({
|
||||
// Smart toggle logic: follow the majority, default to adding splits if equal
|
||||
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
|
||||
const noSplitsCount = selectedPositions.length - existingSplitsCount;
|
||||
|
||||
|
||||
// Remove splits only if majority already have splits
|
||||
// If equal (50/50), default to adding splits
|
||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
|
||||
|
||||
const newSplitPositions = new Set(splitPositions);
|
||||
|
||||
|
||||
if (shouldRemoveSplits) {
|
||||
// Remove splits from all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
|
||||
@@ -318,8 +316,8 @@ const PageEditor = ({
|
||||
const smartSplitCommand = {
|
||||
execute: () => setSplitPositions(newSplitPositions),
|
||||
undo: () => setSplitPositions(splitPositions),
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
@@ -345,13 +343,13 @@ const PageEditor = ({
|
||||
// Smart toggle logic: follow the majority, default to adding splits if equal
|
||||
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
|
||||
const noSplitsCount = selectedPositions.length - existingSplitsCount;
|
||||
|
||||
|
||||
// Remove splits only if majority already have splits
|
||||
// If equal (50/50), default to adding splits
|
||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
|
||||
const newSplitPositions = new Set(splitPositions);
|
||||
|
||||
|
||||
if (shouldRemoveSplits) {
|
||||
// Remove splits from all selected positions
|
||||
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
|
||||
@@ -364,8 +362,8 @@ const PageEditor = ({
|
||||
const smartSplitCommand = {
|
||||
execute: () => setSplitPositions(newSplitPositions),
|
||||
undo: () => setSplitPositions(splitPositions),
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
description: shouldRemoveSplits
|
||||
? `Remove ${selectedPositions.length} split(s)`
|
||||
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
|
||||
};
|
||||
|
||||
@@ -406,7 +404,7 @@ const PageEditor = ({
|
||||
try {
|
||||
const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage);
|
||||
if (!targetPage) return;
|
||||
|
||||
|
||||
await actions.addFiles(files, { insertAfterPageId: targetPage.id });
|
||||
} catch (error) {
|
||||
console.error('Failed to insert files:', error);
|
||||
@@ -445,8 +443,8 @@ const PageEditor = ({
|
||||
}, [displayDocument, getPageNumbersFromIds]);
|
||||
|
||||
// Helper function to collect source files for multi-file export
|
||||
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
|
||||
const sourceFiles = new Map<FileId, File>();
|
||||
const getSourceFiles = useCallback((): Map<string, File> | null => {
|
||||
const sourceFiles = new Map<string, File>();
|
||||
|
||||
// Always include original files
|
||||
activeFileIds.forEach(fileId => {
|
||||
@@ -459,7 +457,7 @@ const PageEditor = ({
|
||||
// Use multi-file export if we have multiple original files
|
||||
const hasInsertedFiles = false;
|
||||
const hasMultipleOriginalFiles = activeFileIds.length > 1;
|
||||
|
||||
|
||||
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
|
||||
return null; // Use single-file export method
|
||||
}
|
||||
@@ -501,7 +499,7 @@ const PageEditor = ({
|
||||
|
||||
// Step 2: Use the already selected page IDs
|
||||
// Filter to only include IDs that exist in the document with DOM state
|
||||
const validSelectedPageIds = selectedPageIds.filter(pageId =>
|
||||
const validSelectedPageIds = selectedPageIds.filter(pageId =>
|
||||
documentWithDOMState.pages.some(p => p.id === pageId)
|
||||
);
|
||||
|
||||
@@ -553,11 +551,11 @@ const PageEditor = ({
|
||||
const sourceFiles = getSourceFiles();
|
||||
const baseExportFilename = getExportFilename();
|
||||
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
|
||||
|
||||
|
||||
for (let i = 0; i < processedDocuments.length; i++) {
|
||||
const doc = processedDocuments[i];
|
||||
const partFilename = `${baseName}_part_${i + 1}.pdf`;
|
||||
|
||||
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
|
||||
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
|
||||
@@ -624,7 +622,6 @@ const PageEditor = ({
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
actions.clearAllFiles();
|
||||
|
||||
undoManagerRef.current.clear();
|
||||
setSelectedPageIds([]);
|
||||
setSelectionMode(false);
|
||||
@@ -635,7 +632,7 @@ const PageEditor = ({
|
||||
if (!displayDocument) return;
|
||||
|
||||
// For now, trigger the actual export directly
|
||||
// In the original, this would show a preview modal first
|
||||
// In the original, this would show a preview modal first
|
||||
if (selectedOnly) {
|
||||
onExportSelected();
|
||||
} else {
|
||||
@@ -683,11 +680,11 @@ const PageEditor = ({
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<Box pos="relative" h="100vh" pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
|
||||
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
|
||||
<Center h='100%'>
|
||||
<Center h="100vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📄</Text>
|
||||
<Text c="dimmed">No PDF files loaded</Text>
|
||||
@@ -726,23 +723,23 @@ const PageEditor = ({
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
|
||||
const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
|
||||
|
||||
|
||||
return Array.from(splitPositions).map((position) => {
|
||||
|
||||
|
||||
// Calculate items per row using DragDropGrid's logic
|
||||
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
||||
const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap));
|
||||
|
||||
|
||||
// Calculate position within the grid (same as DragDropGrid)
|
||||
const row = Math.floor(position / itemsPerRow);
|
||||
const col = position % itemsPerRow;
|
||||
|
||||
|
||||
// Position split line between pages (after the current page)
|
||||
// Calculate grid centering offset (same as DragDropGrid)
|
||||
const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP;
|
||||
const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2);
|
||||
|
||||
|
||||
const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2);
|
||||
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ interface PageEditorControlsProps {
|
||||
selectionMode: boolean;
|
||||
selectedPageIds: string[];
|
||||
displayDocument?: { pages: { id: string; pageNumber: number }[] };
|
||||
|
||||
|
||||
// Split state (for tooltip logic)
|
||||
splitPositions?: Set<number>;
|
||||
totalPages?: number;
|
||||
@@ -70,40 +70,40 @@ const PageEditorControls = ({
|
||||
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
|
||||
// If equal (50/50), default to adding splits
|
||||
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
|
||||
if (willRemoveSplits) {
|
||||
return existingSplitsCount === selectedSplitPositions.length
|
||||
? "Remove All Selected Splits"
|
||||
return existingSplitsCount === selectedSplitPositions.length
|
||||
? "Remove All Selected Splits"
|
||||
: "Remove Selected Splits";
|
||||
} else {
|
||||
return existingSplitsCount === 0
|
||||
? "Split Selected"
|
||||
return existingSplitsCount === 0
|
||||
? "Split Selected"
|
||||
: "Complete Selected Splits";
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate page break tooltip text
|
||||
const getPageBreakTooltip = () => {
|
||||
return selectedPageIds.length > 0
|
||||
return selectedPageIds.length > 0
|
||||
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? 's' : ''}`
|
||||
: "Insert Page Breaks";
|
||||
};
|
||||
@@ -141,7 +141,7 @@ const PageEditorControls = ({
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
padding: "1rem",
|
||||
paddingBottom: "1rem"
|
||||
paddingBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FileId } from '../../../types/file';
|
||||
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
|
||||
|
||||
// V1-style DOM-first command system (replaces the old React state commands)
|
||||
@@ -84,18 +83,18 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
};
|
||||
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 =>
|
||||
const remainingPages = currentDoc.pages.filter(page =>
|
||||
!this.pageIdsToDelete.includes(page.id)
|
||||
);
|
||||
|
||||
@@ -173,20 +172,20 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
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;
|
||||
});
|
||||
@@ -238,13 +237,13 @@ export class SplitCommand extends DOMCommand {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -283,7 +282,7 @@ export class BulkRotateCommand extends DOMCommand {
|
||||
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\(([^)]+)\)/);
|
||||
@@ -355,7 +354,7 @@ export class BulkSplitCommand extends DOMCommand {
|
||||
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>,
|
||||
@@ -367,15 +366,15 @@ export class SplitAllCommand extends DOMCommand {
|
||||
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());
|
||||
@@ -384,12 +383,12 @@ export class SplitAllCommand extends DOMCommand {
|
||||
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));
|
||||
@@ -454,7 +453,7 @@ export class PageBreakCommand extends DOMCommand {
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
|
||||
// No need to maintain selection - page IDs remain stable, so selection persists automatically
|
||||
}
|
||||
|
||||
@@ -530,7 +529,7 @@ export class BulkPageBreakCommand extends DOMCommand {
|
||||
};
|
||||
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
|
||||
// Maintain existing selection by mapping original selected pages to their new positions
|
||||
const updatedSelection: number[] = [];
|
||||
this.originalSelectedPages.forEach(originalPageNum => {
|
||||
@@ -559,9 +558,9 @@ export class BulkPageBreakCommand extends DOMCommand {
|
||||
export class InsertFilesCommand extends DOMCommand {
|
||||
private insertedPages: PDFPage[] = [];
|
||||
private originalDocument: PDFDocument | null = null;
|
||||
private fileDataMap = new Map<FileId, ArrayBuffer>(); // Store file data for thumbnail generation
|
||||
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<FileId, File>(); // Store inserted files for export
|
||||
private insertedFileMap = new Map<string, File>(); // Store inserted files for export
|
||||
|
||||
constructor(
|
||||
private files: File[],
|
||||
@@ -570,7 +569,7 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSelectedPages: () => number[],
|
||||
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void
|
||||
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<string, File>) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -588,19 +587,21 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
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}` as FileId;
|
||||
// Generate proper UUID for each inserted file to prevent collisions
|
||||
const { createFileId } = await import('../../../types/fileContext');
|
||||
const fileId = `inserted-${createFileId()}`;
|
||||
// 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);
|
||||
// Use UUID instead of timestamp for better collision avoidance
|
||||
return await this.extractPagesFromFile(file, Date.now() + index);
|
||||
});
|
||||
|
||||
|
||||
const extractedPageArrays = await Promise.all(extractionPromises);
|
||||
|
||||
|
||||
// Flatten all extracted pages
|
||||
for (const pages of extractedPageArrays) {
|
||||
allNewPages.push(...pages);
|
||||
@@ -659,7 +660,7 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
// 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
|
||||
@@ -669,7 +670,7 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
updatedSelection.push(originalPageNum + allNewPages.length);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
this.setSelectedPages(updatedSelection);
|
||||
|
||||
} catch (error) {
|
||||
@@ -684,35 +685,32 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
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<FileId, PDFPage[]>();
|
||||
|
||||
const pagesByFileId = new Map<string, PDFPage[]>();
|
||||
|
||||
for (const page of this.insertedPages) {
|
||||
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */;
|
||||
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;
|
||||
});
|
||||
|
||||
const pageNumbers = pages.map(page => page.originalPageNumber);
|
||||
|
||||
console.log('Generating thumbnails for page numbers:', pageNumbers);
|
||||
|
||||
|
||||
// Generate thumbnails for all pages from this file at once
|
||||
const results = await thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
@@ -720,14 +718,14 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
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) {
|
||||
@@ -736,7 +734,7 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Trigger re-render by updating the document
|
||||
this.setDocument({ ...updatedDocument });
|
||||
} else {
|
||||
@@ -755,7 +753,7 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
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;
|
||||
@@ -763,24 +761,26 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
|
||||
// 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}` as FileId;
|
||||
|
||||
// Generate proper UUID for inserted file to prevent collisions
|
||||
const { createFileId } = await import('../../../types/fileContext');
|
||||
const fileId = `inserted-${createFileId()}`;
|
||||
|
||||
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({
|
||||
@@ -794,10 +794,10 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
isBlankPage: false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Clean up PDF document
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
|
||||
resolve(pages);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
@@ -877,4 +877,4 @@ export class UndoManager {
|
||||
this.redoStack = [];
|
||||
this.onStateChange?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useFileState } from '../../../contexts/FileContext';
|
||||
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
|
||||
import { FileId } from '../../../types/file';
|
||||
import { FileId } from '../../../types/fileContext';
|
||||
|
||||
export interface PageDocumentHook {
|
||||
document: PDFDocument | null;
|
||||
@@ -15,17 +15,17 @@ export interface PageDocumentHook {
|
||||
*/
|
||||
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;
|
||||
@@ -36,7 +36,7 @@ export function usePageDocument(): PageDocumentHook {
|
||||
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');
|
||||
@@ -51,9 +51,9 @@ export function usePageDocument(): PageDocumentHook {
|
||||
.join(' + ');
|
||||
|
||||
// Build page insertion map from files with insertion positions
|
||||
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
|
||||
const originalFileIds: FileId[] = [];
|
||||
|
||||
const insertionMap = new Map<string, string[]>(); // insertAfterPageId -> fileIds
|
||||
const originalFileIds: string[] = [];
|
||||
|
||||
activeFileIds.forEach(fileId => {
|
||||
const record = selectors.getFileRecord(fileId);
|
||||
if (record?.insertAfterPageId !== undefined) {
|
||||
@@ -65,21 +65,21 @@ export function usePageDocument(): PageDocumentHook {
|
||||
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: FileId, 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) => ({
|
||||
@@ -105,45 +105,45 @@ export function usePageDocument(): PageDocumentHook {
|
||||
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
|
||||
const filePages = createPagesFromFile(fileId as FileId, 1); // Temporary numbering
|
||||
originalFilePages.push(...filePages);
|
||||
});
|
||||
|
||||
// Start with all original pages numbered sequentially
|
||||
|
||||
// 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);
|
||||
const insertedPages = createPagesFromFile(fileId as 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) {
|
||||
@@ -174,4 +174,4 @@ export function usePageDocument(): PageDocumentHook {
|
||||
isVeryLargeDocument,
|
||||
isLoading
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import SearchIcon from "@mui/icons-material/Search";
|
||||
import SortIcon from "@mui/icons-material/Sort";
|
||||
import FileCard from "./FileCard";
|
||||
import { FileRecord } from "../../types/fileContext";
|
||||
import { FileId } from "../../types/file";
|
||||
|
||||
interface FileGridProps {
|
||||
files: Array<{ file: File; record?: FileRecord }>;
|
||||
@@ -13,8 +12,8 @@ interface FileGridProps {
|
||||
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onView?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
||||
onSelect?: (fileId: FileId) => void;
|
||||
selectedFiles?: FileId[];
|
||||
onSelect?: (fileId: string) => void;
|
||||
selectedFiles?: string[];
|
||||
showSearch?: boolean;
|
||||
showSort?: boolean;
|
||||
maxDisplay?: number; // If set, shows only this many files with "Show All" option
|
||||
@@ -120,12 +119,16 @@ const FileGrid = ({
|
||||
direction="row"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
h="30rem"
|
||||
h="30rem"
|
||||
style={{ overflowY: "auto", width: "100%" }}
|
||||
>
|
||||
{displayFiles.map((item, idx) => {
|
||||
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */;
|
||||
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
||||
if (!item.record?.id) {
|
||||
console.error('FileGrid: File missing FileRecord with proper ID:', item.file.name);
|
||||
return null;
|
||||
}
|
||||
const fileId = item.record.id;
|
||||
const originalIdx = files.findIndex(f => f.record?.id === fileId);
|
||||
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||
return (
|
||||
<FileCard
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
import {
|
||||
Modal,
|
||||
Text,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Checkbox,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Image,
|
||||
Badge,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FilePickerModalProps {
|
||||
opened: boolean;
|
||||
@@ -31,7 +30,7 @@ const FilePickerModal = ({
|
||||
onSelectFiles,
|
||||
}: FilePickerModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
// Reset selection when modal opens
|
||||
useEffect(() => {
|
||||
@@ -40,9 +39,9 @@ const FilePickerModal = ({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const toggleFileSelection = (fileId: FileId) => {
|
||||
const toggleFileSelection = (fileId: string) => {
|
||||
setSelectedFileIds(prev => {
|
||||
return prev.includes(fileId)
|
||||
return prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId];
|
||||
});
|
||||
@@ -57,10 +56,10 @@ const FilePickerModal = ({
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const selectedFiles = storedFiles.filter(f =>
|
||||
const selectedFiles = storedFiles.filter(f =>
|
||||
selectedFileIds.includes(f.id)
|
||||
);
|
||||
|
||||
|
||||
// Convert stored files to File objects
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(async (fileItem) => {
|
||||
@@ -69,12 +68,12 @@ const FilePickerModal = ({
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem;
|
||||
}
|
||||
|
||||
|
||||
// If it has a file property, use that
|
||||
if (fileItem.file && fileItem.file instanceof File) {
|
||||
return fileItem.file;
|
||||
}
|
||||
|
||||
|
||||
// If it's from IndexedDB storage, reconstruct the File
|
||||
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
|
||||
const arrayBuffer = await fileItem.arrayBuffer();
|
||||
@@ -84,8 +83,8 @@ const FilePickerModal = ({
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
// If it has data property, reconstruct the File
|
||||
|
||||
// If it has data property, reconstruct the File
|
||||
if (fileItem.data) {
|
||||
const blob = new Blob([fileItem.data], { type: fileItem.type || 'application/pdf' });
|
||||
return new File([blob], fileItem.name, {
|
||||
@@ -93,7 +92,7 @@ const FilePickerModal = ({
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
console.warn('Could not convert file item:', fileItem);
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -102,10 +101,10 @@ const FilePickerModal = ({
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Filter out any null values and return valid Files
|
||||
const validFiles = convertedFiles.filter((f): f is File => f !== null);
|
||||
|
||||
|
||||
onSelectFiles(validFiles);
|
||||
onClose();
|
||||
};
|
||||
@@ -157,18 +156,18 @@ const FilePickerModal = ({
|
||||
{storedFiles.map((file) => {
|
||||
const fileId = file.id;
|
||||
const isSelected = selectedFileIds.includes(fileId);
|
||||
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={fileId}
|
||||
p="sm"
|
||||
style={{
|
||||
border: isSelected
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
border: isSelected
|
||||
? '2px solid var(--mantine-color-blue-6)'
|
||||
: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: 8,
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-blue-0)'
|
||||
: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease'
|
||||
@@ -181,7 +180,7 @@ const FilePickerModal = ({
|
||||
onChange={() => toggleFileSelection(fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
|
||||
{/* Thumbnail */}
|
||||
<Box
|
||||
style={{
|
||||
@@ -247,11 +246,11 @@ const FilePickerModal = ({
|
||||
<Button variant="light" onClick={onClose}>
|
||||
{t("close", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedFileIds.length === 0}
|
||||
>
|
||||
{selectedFileIds.length > 0
|
||||
{selectedFileIds.length > 0
|
||||
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
|
||||
: t("fileUpload.loadFromStorage", "Load Files")
|
||||
}
|
||||
@@ -262,4 +261,4 @@ const FilePickerModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePickerModal;
|
||||
export default FilePickerModal;
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCookieConsent } from '../../hooks/useCookieConsent';
|
||||
|
||||
interface FooterProps {
|
||||
privacyPolicy?: string;
|
||||
termsAndConditions?: string;
|
||||
accessibilityStatement?: string;
|
||||
cookiePolicy?: string;
|
||||
impressum?: string;
|
||||
analyticsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
privacyPolicy = '/privacy',
|
||||
termsAndConditions = '/terms',
|
||||
accessibilityStatement = 'accessibility',
|
||||
analyticsEnabled = false
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: 'var(--footer-height)',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderTop: '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Flex gap="md"
|
||||
justify="center"
|
||||
align="center"
|
||||
direction="row"
|
||||
style={{ fontSize: '0.75rem' }}>
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
id="survey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://stirlingpdf.info/s/cm28y3niq000o56dv7liv8wsu"
|
||||
>
|
||||
{t('survey.nav', 'Survey')}
|
||||
</a>
|
||||
{privacyPolicy && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={privacyPolicy}
|
||||
>
|
||||
{t('legal.privacy', 'Privacy Policy')}
|
||||
</a>
|
||||
)}
|
||||
{termsAndConditions && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={termsAndConditions}
|
||||
>
|
||||
{t('legal.terms', 'Terms and Conditions')}
|
||||
</a>
|
||||
)}
|
||||
{accessibilityStatement && (
|
||||
<a
|
||||
className="footer-link px-3"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={accessibilityStatement}
|
||||
>
|
||||
{t('legal.accessibility', 'Accessibility')}
|
||||
</a>
|
||||
)}
|
||||
{analyticsEnabled && (
|
||||
<button
|
||||
className="footer-link px-3"
|
||||
id="cookieBanner"
|
||||
onClick={showCookiePreferences}
|
||||
>
|
||||
{t('legal.showCookieBanner', 'Cookie Preferences')}
|
||||
</button>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,13 +36,13 @@ const LandingPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="70rem" 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}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
className="w-4/5 flex items-center justify-center h-[95vh]"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
@@ -189,7 +189,7 @@ const LandingPage = () => {
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click to upload')}
|
||||
</span>
|
||||
</div>
|
||||
</Dropzone>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function RightRail() {
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
|
||||
// Navigation view
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { currentMode: currentView } = useNavigationState();
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
@@ -5,7 +5,7 @@ import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
|
||||
import { ModeType, isValidMode } from '../../contexts/NavigationContext';
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
const viewOptionStyle = {
|
||||
@@ -19,7 +19,7 @@ const viewOptionStyle = {
|
||||
|
||||
|
||||
// Build view options showing text only for current view; others icon-only with tooltip
|
||||
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
|
||||
const createViewOptions = (currentView: ModeType, switchingTo: ModeType | null) => [
|
||||
{
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
@@ -70,8 +70,8 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
|
||||
];
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
currentView: ModeType;
|
||||
setCurrentView: (view: ModeType) => void;
|
||||
selectedToolKey?: string | null;
|
||||
}
|
||||
|
||||
@@ -81,25 +81,25 @@ const TopControls = ({
|
||||
selectedToolKey,
|
||||
}: TopControlsProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
const [switchingTo, setSwitchingTo] = useState<ModeType | null>(null);
|
||||
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
if (!isValidWorkbench(view)) {
|
||||
if (!isValidMode(view)) {
|
||||
// Ignore invalid values defensively
|
||||
return;
|
||||
}
|
||||
|
||||
const workbench = view;
|
||||
const mode = view as ModeType;
|
||||
|
||||
// Show immediate feedback
|
||||
setSwitchingTo(workbench);
|
||||
setSwitchingTo(mode as ModeType);
|
||||
|
||||
// Defer the heavy view change to next frame so spinner can render
|
||||
requestAnimationFrame(() => {
|
||||
// Give the spinner one more frame to show
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentView(workbench);
|
||||
setCurrentView(mode as ModeType);
|
||||
|
||||
// Clear the loading state after view change completes
|
||||
setTimeout(() => setSwitchingTo(null), 300);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useToolSections } from '../../hooks/useToolSections';
|
||||
import SubcategoryHeader from './shared/SubcategoryHeader';
|
||||
import NoToolsFound from './shared/NoToolsFound';
|
||||
import "./toolPicker/ToolPicker.css";
|
||||
|
||||
interface SearchResultsProps {
|
||||
filteredTools: [string, ToolRegistryEntry][];
|
||||
@@ -22,12 +21,11 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack p="sm" gap="xs"
|
||||
className="tool-picker-scrollable">
|
||||
<Stack p="sm" gap="xs">
|
||||
{searchGroups.map(group => (
|
||||
<Box key={group.subcategoryId} w="100%">
|
||||
<Box key={group.subcategoryId} w="100%">
|
||||
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
|
||||
<Stack gap="xs">
|
||||
<Stack gap="xs">
|
||||
{group.tools.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={id}
|
||||
|
||||
@@ -72,15 +72,17 @@ export default function ToolPanel() {
|
||||
|
||||
{searchQuery.trim().length > 0 ? (
|
||||
// Searching view (replaces both picker and content)
|
||||
<div className="flex-1 flex flex-col overflow-y-auto">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex-1 min-h-0">
|
||||
<SearchResults
|
||||
filteredTools={filteredTools}
|
||||
onSelect={handleToolSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : leftPanelView === 'toolPicker' ? (
|
||||
// Tool Picker View
|
||||
<div className="flex-1 flex flex-col overflow-auto">
|
||||
<div className="flex-1 flex flex-col">
|
||||
<ToolPicker
|
||||
selectedToolKey={selectedToolKey}
|
||||
onSelect={handleToolSelect}
|
||||
|
||||
@@ -91,7 +91,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
|
||||
return (
|
||||
<Box
|
||||
h="100%"
|
||||
h="100vh"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Modal
|
||||
} from '@mantine/core';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import { ToolRegistry } from '../../../data/toolsTaxonomy';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import ToolConfigurationModal from './ToolConfigurationModal';
|
||||
import ToolList from './ToolList';
|
||||
import IconSelector from './IconSelector';
|
||||
@@ -24,7 +24,7 @@ interface AutomationCreationProps {
|
||||
existingAutomation?: AutomationConfig;
|
||||
onBack: () => void;
|
||||
onComplete: (automation: AutomationConfig) => void;
|
||||
toolRegistry: ToolRegistry;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
}
|
||||
|
||||
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
|
||||
React.useEffect(() => {
|
||||
if (automation?.operations) {
|
||||
const steps = automation.operations.map((op: any, index: number) => {
|
||||
const tool = toolRegistry[op.operation as keyof typeof toolRegistry];
|
||||
const tool = toolRegistry[op.operation];
|
||||
return {
|
||||
id: `${op.operation}-${index}`,
|
||||
operation: op.operation,
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
|
||||
// Get tool info from registry
|
||||
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
|
||||
const toolInfo = toolRegistry[tool.operation];
|
||||
const SettingsComponent = toolInfo?.settingsComponent;
|
||||
|
||||
// Initialize parameters from tool (which should contain defaults from registry)
|
||||
|
||||
@@ -15,25 +15,25 @@ import ConvertFromWebSettings from "./ConvertFromWebSettings";
|
||||
import ConvertFromEmailSettings from "./ConvertFromEmailSettings";
|
||||
import ConvertToPdfaSettings from "./ConvertToPdfaSettings";
|
||||
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
|
||||
import {
|
||||
import {
|
||||
FROM_FORMAT_OPTIONS,
|
||||
EXTENSION_TO_ENDPOINT,
|
||||
COLOR_TYPES,
|
||||
OUTPUT_OPTIONS,
|
||||
FIT_OPTIONS
|
||||
} from "../../../constants/convertConstants";
|
||||
import { FileId } from "../../../types/file";
|
||||
import { FileWithId } from "../../../types/fileContext";
|
||||
|
||||
interface ConvertSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: FileWithId[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertSettings = ({
|
||||
parameters,
|
||||
const ConvertSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
getAvailableToExtensions,
|
||||
selectedFiles,
|
||||
@@ -53,7 +53,7 @@ const ConvertSettings = ({
|
||||
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (!endpointKey) return false;
|
||||
|
||||
|
||||
return endpointStatus[endpointKey] === true;
|
||||
};
|
||||
|
||||
@@ -62,10 +62,10 @@ const ConvertSettings = ({
|
||||
const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
|
||||
// Check if this source format has any available conversions
|
||||
const availableConversions = getAvailableToExtensions(option.value) || [];
|
||||
const hasAvailableConversions = availableConversions.some(targetOption =>
|
||||
const hasAvailableConversions = availableConversions.some(targetOption =>
|
||||
isConversionAvailable(option.value, targetOption.value)
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
...option,
|
||||
enabled: hasAvailableConversions
|
||||
@@ -81,18 +81,18 @@ const ConvertSettings = ({
|
||||
group: 'File',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
|
||||
// Add the dynamic option at the beginning
|
||||
return [dynamicOption, ...baseOptions];
|
||||
}
|
||||
|
||||
|
||||
return baseOptions;
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
|
||||
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
|
||||
return availableOptions.map(option => ({
|
||||
...option,
|
||||
@@ -129,10 +129,10 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
const filterFilesByExtension = (extension: string) => {
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
||||
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as FileWithId[];
|
||||
return files.filter(file => {
|
||||
const fileExtension = detectFileExtension(file.name);
|
||||
|
||||
|
||||
if (extension === 'any') {
|
||||
return true;
|
||||
} else if (extension === 'image') {
|
||||
@@ -143,21 +143,8 @@ const ConvertSettings = ({
|
||||
});
|
||||
};
|
||||
|
||||
const updateFileSelection = (files: File[]) => {
|
||||
// Map File objects to their actual IDs in FileContext
|
||||
const fileIds = files.map(file => {
|
||||
// Find the file ID by matching file properties
|
||||
const fileRecord = state.files.ids
|
||||
.map(id => selectors.getFileRecord(id))
|
||||
.find(record =>
|
||||
record &&
|
||||
record.name === file.name &&
|
||||
record.size === file.size &&
|
||||
record.lastModified === file.lastModified
|
||||
);
|
||||
return fileRecord?.id;
|
||||
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
|
||||
|
||||
const updateFileSelection = (files: FileWithId[]) => {
|
||||
const fileIds = files.map(file => file.fileId);
|
||||
setSelectedFiles(fileIds);
|
||||
};
|
||||
|
||||
@@ -165,7 +152,7 @@ const ConvertSettings = ({
|
||||
onParameterChange('fromExtension', value);
|
||||
setAutoTargetExtension(value);
|
||||
resetParametersToDefaults();
|
||||
|
||||
|
||||
if (activeFiles.length > 0) {
|
||||
const matchingFiles = filterFilesByExtension(value);
|
||||
updateFileSelection(matchingFiles);
|
||||
@@ -233,11 +220,11 @@ const ConvertSettings = ({
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">{t("convert.selectSourceFormatFirst", "Select a source format first")}</Text>
|
||||
<KeyboardArrowDownIcon
|
||||
style={{
|
||||
<KeyboardArrowDownIcon
|
||||
style={{
|
||||
fontSize: '1rem',
|
||||
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
@@ -267,9 +254,9 @@ const ConvertSettings = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Color options for image to PDF conversion */}
|
||||
{(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
{(isImageFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'images') ? (
|
||||
<>
|
||||
<Divider />
|
||||
@@ -282,7 +269,7 @@ const ConvertSettings = ({
|
||||
) : null}
|
||||
|
||||
{/* Web to PDF options */}
|
||||
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
{((isWebFormat(parameters.fromExtension) && parameters.toExtension === 'pdf') ||
|
||||
(parameters.isSmartDetection && parameters.smartDetectionType === 'web')) ? (
|
||||
<>
|
||||
<Divider />
|
||||
@@ -323,4 +310,4 @@ const ConvertSettings = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertSettings;
|
||||
export default ConvertSettings;
|
||||
@@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
|
||||
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
|
||||
import { FileWithId } from '../../../types/fileContext';
|
||||
|
||||
interface ConvertToPdfaSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
selectedFiles: File[];
|
||||
selectedFiles: FileWithId[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
|
||||
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
|
||||
import { useAllFiles } from "../../../contexts/FileContext";
|
||||
import { useFileManager } from "../../../hooks/useFileManager";
|
||||
import { FileWithId } from "../../../types/fileContext";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: File[];
|
||||
selectedFiles?: FileWithId[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ const FileStatusIndicator = ({
|
||||
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }}
|
||||
>
|
||||
<UploadIcon style={{ fontSize: '0.875rem' }} />
|
||||
{t("files.uploadFiles", "Upload Files")}
|
||||
{t("files.upload", "Upload")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileStatusIndicator from './FileStatusIndicator';
|
||||
import { FileWithId } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesToolStepProps {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: FileWithId[];
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
placeholder?: string;
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Button, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ErrorNotification from './ErrorNotification';
|
||||
import ResultsPreview from './ResultsPreview';
|
||||
import { SuggestedToolsSection } from './SuggestedToolsSection';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import ErrorNotification from "./ErrorNotification";
|
||||
import ResultsPreview from "./ResultsPreview";
|
||||
import { SuggestedToolsSection } from "./SuggestedToolsSection";
|
||||
import { ToolOperationHook } from "../../../hooks/tools/shared/useToolOperation";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
|
||||
export interface ReviewToolStepProps<TParams = unknown> {
|
||||
isVisible: boolean;
|
||||
operation: ToolOperationHook<TParams>;
|
||||
title?: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
}
|
||||
|
||||
function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { operation: ToolOperationHook<TParams>; onFileClick?: (file: File) => void }) {
|
||||
function ReviewStepContent<TParams = unknown>({
|
||||
operation,
|
||||
onFileClick,
|
||||
onUndo,
|
||||
}: {
|
||||
operation: ToolOperationHook<TParams>;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stepRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const previewFiles = operation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: operation.thumbnails[index]
|
||||
})) || [];
|
||||
const handleUndo = async () => {
|
||||
try {
|
||||
onUndo();
|
||||
} catch (error) {
|
||||
// Error is already handled by useToolOperation, just reset loading state
|
||||
console.error("Undo operation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const previewFiles =
|
||||
operation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: operation.thumbnails[index],
|
||||
})) || [];
|
||||
|
||||
// Auto-scroll to bottom when content appears
|
||||
useEffect(() => {
|
||||
@@ -31,7 +52,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
||||
setTimeout(() => {
|
||||
scrollableContainer.scrollTo({
|
||||
top: scrollableContainer.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, 100); // Small delay to ensure content is rendered
|
||||
}
|
||||
@@ -40,10 +61,7 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
||||
|
||||
return (
|
||||
<Stack gap="sm" ref={stepRef}>
|
||||
<ErrorNotification
|
||||
error={operation.errorMessage}
|
||||
onClose={operation.clearError}
|
||||
/>
|
||||
<ErrorNotification error={operation.errorMessage} onClose={operation.clearError} />
|
||||
|
||||
{previewFiles.length > 0 && (
|
||||
<ResultsPreview
|
||||
@@ -53,7 +71,18 @@ function ReviewStepContent<TParams = unknown>({ operation, onFileClick }: { oper
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation.downloadUrl && (
|
||||
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
|
||||
<Button
|
||||
leftSection={<UndoIcon />}
|
||||
variant="outline"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
onClick={handleUndo}
|
||||
fullWidth
|
||||
>
|
||||
{t("undo", "Undo")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{operation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={operation.downloadUrl}
|
||||
@@ -78,14 +107,13 @@ export function createReviewToolStep<TParams = unknown>(
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createStep(t("review", "Review"), {
|
||||
isVisible: props.isVisible,
|
||||
_excludeFromCount: true,
|
||||
_noPadding: true
|
||||
}, (
|
||||
<ReviewStepContent
|
||||
operation={props.operation}
|
||||
onFileClick={props.onFileClick}
|
||||
/>
|
||||
));
|
||||
return createStep(
|
||||
t("review", "Review"),
|
||||
{
|
||||
isVisible: props.isVisible,
|
||||
_excludeFromCount: true,
|
||||
_noPadding: true,
|
||||
},
|
||||
<ReviewStepContent operation={props.operation} onFileClick={props.onFileClick} onUndo={props.onUndo} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
|
||||
import OperationButton from './OperationButton';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
|
||||
import { FileWithId } from '../../../types/fileContext';
|
||||
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: File[];
|
||||
selectedFiles: FileWithId[];
|
||||
isCollapsed?: boolean;
|
||||
placeholder?: string;
|
||||
onCollapsedClick?: () => void;
|
||||
@@ -43,6 +44,7 @@ export interface ReviewStepConfig {
|
||||
operation: ToolOperationHook<any>;
|
||||
title: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
@@ -105,7 +107,8 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
isVisible: config.review.isVisible,
|
||||
operation: config.review.operation,
|
||||
title: config.review.title,
|
||||
onFileClick: config.review.onFileClick
|
||||
onFileClick: config.review.onFileClick,
|
||||
onUndo: config.review.onUndo
|
||||
})}
|
||||
</ToolStepProvider>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.tool-picker-scrollable {
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mantine-color-gray-4) transparent;
|
||||
}
|
||||
|
||||
.tool-picker-scrollable::-webkit-scrollbar {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fileStorage } from "../../services/fileStorage";
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||
import { FileId } from "../../types/file";
|
||||
import { isFileObject } from "../../types/fileContext";
|
||||
|
||||
|
||||
// Lazy loading page image component
|
||||
@@ -153,7 +153,7 @@ const Viewer = ({
|
||||
const { selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const currentFile = useCurrentFile();
|
||||
|
||||
|
||||
const getCurrentFile = () => currentFile.file;
|
||||
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
|
||||
const clearAllFiles = actions.clearAllFiles;
|
||||
@@ -201,7 +201,7 @@ const Viewer = ({
|
||||
const effectiveFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
// Validate the preview file
|
||||
if (!(previewFile instanceof File)) {
|
||||
if (!isFileObject(previewFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ const Viewer = ({
|
||||
}
|
||||
// Handle special IndexedDB URLs for large files
|
||||
else if (effectiveFile.url?.startsWith('indexeddb:')) {
|
||||
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
|
||||
const fileId = effectiveFile.url.replace('indexeddb:', '');
|
||||
|
||||
// Get data directly from IndexedDB
|
||||
const arrayBuffer = await fileStorage.getFileData(fileId);
|
||||
@@ -439,7 +439,7 @@ const Viewer = ({
|
||||
}, [pageImages]);
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'relative', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box style={{ position: 'relative', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Close Button - Only show in preview mode */}
|
||||
{onClose && previewFile && (
|
||||
<ActionIcon
|
||||
@@ -558,7 +558,7 @@ const Viewer = ({
|
||||
radius="xl xl 0 0"
|
||||
shadow="sm"
|
||||
p={12}
|
||||
pb={12}
|
||||
pb={24}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -19,17 +19,19 @@ import {
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue,
|
||||
FileContextActions,
|
||||
FileRecord
|
||||
FileId,
|
||||
FileRecord,
|
||||
FileWithId,
|
||||
createFileWithId
|
||||
} from '../types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
import { fileContextReducer, initialFileContextState } from './file/FileReducer';
|
||||
import { createFileSelectors } from './file/fileSelectors';
|
||||
import { AddedFile, addFiles, consumeFiles, createFileActions } from './file/fileActions';
|
||||
import { addFiles, consumeFiles, undoConsumeFiles, createFileActions } from './file/fileActions';
|
||||
import { FileLifecycleManager } from './file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from './file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -72,14 +74,14 @@ function FileContextInner({
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||
}, []);
|
||||
|
||||
const selectFiles = (addedFilesWithIds: AddedFile[]) => {
|
||||
const selectFiles = useCallback((addedFilesWithIds: Array<{ file: File; id: FileId; thumbnail?: string }>) => {
|
||||
const currentSelection = stateRef.current.ui.selectedFileIds;
|
||||
const newFileIds = addedFilesWithIds.map(({ id }) => id);
|
||||
dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds: [...currentSelection, ...newFileIds] } });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// File operations using unified addFiles helper with persistence
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<File[]> => {
|
||||
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<FileWithId[]> => {
|
||||
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
@@ -98,15 +100,15 @@ function FileContextInner({
|
||||
}));
|
||||
}
|
||||
|
||||
return addedFilesWithIds.map(({ file }) => file);
|
||||
return addedFilesWithIds.map(({ file, id }) => createFileWithId(file, id));
|
||||
}, [indexedDB, enablePersistence]);
|
||||
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => {
|
||||
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<FileWithId[]> => {
|
||||
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
return result.map(({ file }) => file);
|
||||
return result.map(({ file, id }) => createFileWithId(file, id));
|
||||
}, []);
|
||||
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<File[]> => {
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<FileWithId[]> => {
|
||||
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
|
||||
|
||||
// Auto-select the newly added files if requested
|
||||
@@ -114,46 +116,28 @@ function FileContextInner({
|
||||
selectFiles(result);
|
||||
}
|
||||
|
||||
return result.map(({ file }) => file);
|
||||
return result.map(({ file, id }) => createFileWithId(file, id));
|
||||
}, []);
|
||||
|
||||
// Action creators
|
||||
const baseActions = useMemo(() => createFileActions(dispatch), []);
|
||||
|
||||
// Helper functions for pinned files
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<void> => {
|
||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
|
||||
}, []);
|
||||
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
|
||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
|
||||
// Helper to find FileId from File object
|
||||
const findFileId = useCallback((file: File): FileId | undefined => {
|
||||
return (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
|
||||
const storedFile = filesRef.current.get(id);
|
||||
return storedFile &&
|
||||
storedFile.name === file.name &&
|
||||
storedFile.size === file.size &&
|
||||
storedFile.lastModified === file.lastModified;
|
||||
});
|
||||
}, []);
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
|
||||
// File-to-ID wrapper functions for pinning
|
||||
const pinFileWrapper = useCallback((file: File) => {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
baseActions.pinFile(fileId);
|
||||
} else {
|
||||
console.warn('File not found for pinning:', file.name);
|
||||
}
|
||||
}, [baseActions, findFileId]);
|
||||
const pinFileWrapper = useCallback((file: FileWithId) => {
|
||||
baseActions.pinFile(file.fileId);
|
||||
}, [baseActions]);
|
||||
|
||||
const unpinFileWrapper = useCallback((file: File) => {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
baseActions.unpinFile(fileId);
|
||||
} else {
|
||||
console.warn('File not found for unpinning:', file.name);
|
||||
}
|
||||
}, [baseActions, findFileId]);
|
||||
const unpinFileWrapper = useCallback((file: FileWithId) => {
|
||||
baseActions.unpinFile(file.fileId);
|
||||
}, [baseActions]);
|
||||
|
||||
// Complete actions object
|
||||
const actions = useMemo<FileContextActions>(() => ({
|
||||
@@ -206,10 +190,11 @@ function FileContextInner({
|
||||
pinFile: pinFileWrapper,
|
||||
unpinFile: unpinFileWrapper,
|
||||
consumeFiles: consumeFilesWrapper,
|
||||
undoConsumeFiles: undoConsumeFilesWrapper,
|
||||
setHasUnsavedChanges,
|
||||
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
||||
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||
scheduleCleanup: (fileId: FileId, delay?: number) =>
|
||||
cleanupFile: (fileId: string) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||
scheduleCleanup: (fileId: string, delay?: number) =>
|
||||
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
|
||||
}), [
|
||||
baseActions,
|
||||
@@ -219,6 +204,7 @@ function FileContextInner({
|
||||
lifecycleManager,
|
||||
setHasUnsavedChanges,
|
||||
consumeFilesWrapper,
|
||||
undoConsumeFilesWrapper,
|
||||
pinFileWrapper,
|
||||
unpinFileWrapper,
|
||||
indexedDB,
|
||||
|
||||
@@ -2,13 +2,12 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEff
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
||||
import { downloadFiles } from '../utils/downloadUtils';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
// State
|
||||
activeSource: 'recent' | 'local' | 'drive';
|
||||
selectedFileIds: FileId[];
|
||||
selectedFileIds: string[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileMetadata[];
|
||||
filteredFiles: FileMetadata[];
|
||||
@@ -65,7 +64,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
refreshRecentFiles,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -75,10 +74,10 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
|
||||
// Computed values (with null safety)
|
||||
const selectedFilesSet = new Set(selectedFileIds);
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
|
||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
|
||||
|
||||
|
||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
||||
(recentFiles || []).filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
@@ -100,15 +99,15 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
|
||||
const fileId = file.id;
|
||||
if (!fileId) return;
|
||||
|
||||
|
||||
if (shiftKey && lastClickedIndex !== null) {
|
||||
// Range selection with shift-click
|
||||
const startIndex = Math.min(lastClickedIndex, currentIndex);
|
||||
const endIndex = Math.max(lastClickedIndex, currentIndex);
|
||||
|
||||
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
|
||||
// Add all files in the range to selection
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const rangeFileId = filteredFiles[i]?.id;
|
||||
@@ -116,23 +115,23 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
selectedSet.add(rangeFileId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
} else {
|
||||
// Normal click behavior - optimized with Set for O(1) lookup
|
||||
setSelectedFileIds(prev => {
|
||||
const selectedSet = new Set(prev);
|
||||
|
||||
|
||||
if (selectedSet.has(fileId)) {
|
||||
selectedSet.delete(fileId);
|
||||
} else {
|
||||
selectedSet.add(fileId);
|
||||
}
|
||||
|
||||
|
||||
return Array.from(selectedSet);
|
||||
});
|
||||
|
||||
|
||||
// Update last clicked index for future range selections
|
||||
setLastClickedIndex(currentIndex);
|
||||
}
|
||||
@@ -197,7 +196,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
|
||||
try {
|
||||
// Get files to delete based on current filtered view
|
||||
const filesToDelete = filteredFiles.filter(file =>
|
||||
const filesToDelete = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
@@ -222,7 +221,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
|
||||
try {
|
||||
// Get selected files
|
||||
const selectedFilesToDownload = filteredFiles.filter(file =>
|
||||
const selectedFilesToDownload = filteredFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
interface FilesModalContextType {
|
||||
isFilesModalOpen: boolean;
|
||||
@@ -9,7 +8,7 @@ interface FilesModalContextType {
|
||||
closeFilesModal: () => void;
|
||||
onFileSelect: (file: File) => void;
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void;
|
||||
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void;
|
||||
onModalClose?: () => void;
|
||||
setOnModalClose: (callback: () => void) => void;
|
||||
}
|
||||
@@ -58,7 +57,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
closeFilesModal();
|
||||
}, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]);
|
||||
|
||||
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
|
||||
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
|
||||
if (customHandler) {
|
||||
// Use custom handler for special cases (like page insertion)
|
||||
const files = filesWithMetadata.map(item => item.file);
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
import { fileStorage, StoredFile } from '../services/fileStorage';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileId } from '../types/fileContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
|
||||
@@ -17,12 +17,12 @@ interface IndexedDBContextValue {
|
||||
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
|
||||
deleteFile: (fileId: FileId) => Promise<void>;
|
||||
|
||||
|
||||
// Batch operations
|
||||
loadAllMetadata: () => Promise<FileMetadata[]>;
|
||||
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||
clearAll: () => Promise<void>;
|
||||
|
||||
|
||||
// Utilities
|
||||
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
||||
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
||||
@@ -59,14 +59,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
|
||||
// Use existing thumbnail or generate new one if none provided
|
||||
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||
|
||||
|
||||
// Store in IndexedDB
|
||||
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
||||
|
||||
|
||||
// Cache the file object for immediate reuse
|
||||
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||
evictLRUEntries();
|
||||
|
||||
|
||||
// Return metadata
|
||||
return {
|
||||
id: fileId,
|
||||
@@ -121,7 +121,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
// Load metadata from IndexedDB (efficient - no data field)
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
const fileMetadata = metadata.find(m => m.id === fileId);
|
||||
|
||||
|
||||
if (!fileMetadata) return null;
|
||||
|
||||
return {
|
||||
@@ -137,14 +137,14 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileCache.current.delete(fileId);
|
||||
|
||||
|
||||
// Remove from IndexedDB
|
||||
await fileStorage.deleteFile(fileId);
|
||||
}, []);
|
||||
|
||||
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||
const metadata = await fileStorage.getAllFileMetadata();
|
||||
|
||||
|
||||
return metadata.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
@@ -158,7 +158,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||
// Remove from cache
|
||||
fileIds.forEach(id => fileCache.current.delete(id));
|
||||
|
||||
|
||||
// Remove from IndexedDB in parallel
|
||||
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
|
||||
}, []);
|
||||
@@ -166,7 +166,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const clearAll = useCallback(async (): Promise<void> => {
|
||||
// Clear cache
|
||||
fileCache.current.clear();
|
||||
|
||||
|
||||
// Clear IndexedDB
|
||||
await fileStorage.clearAll();
|
||||
}, []);
|
||||
@@ -204,4 +204,4 @@ export function useIndexedDB() {
|
||||
throw new Error('useIndexedDB must be used within an IndexedDBProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,85 @@
|
||||
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||
import { WorkbenchType, getDefaultWorkbench } from '../types/workbench';
|
||||
import { ToolId, isValidToolId } from '../types/toolId';
|
||||
import { useFlatToolRegistry } from '../data/useTranslatedToolRegistry';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
import { ModeType, isValidMode, getDefaultMode } from '../types/navigation';
|
||||
|
||||
/**
|
||||
* NavigationContext - Complete navigation management system
|
||||
*
|
||||
*
|
||||
* Handles navigation modes, navigation guards for unsaved changes,
|
||||
* and breadcrumb/history navigation. Separated from FileContext to
|
||||
* and breadcrumb/history navigation. Separated from FileContext to
|
||||
* maintain clear separation of concerns.
|
||||
*/
|
||||
|
||||
// Navigation state
|
||||
interface NavigationContextState {
|
||||
workbench: WorkbenchType;
|
||||
selectedTool: ToolId | null;
|
||||
interface NavigationState {
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
selectedToolKey: string | null; // Add tool selection to navigation state
|
||||
}
|
||||
|
||||
// Navigation actions
|
||||
type NavigationAction =
|
||||
| { type: 'SET_WORKBENCH'; payload: { workbench: WorkbenchType } }
|
||||
| { type: 'SET_SELECTED_TOOL'; payload: { toolId: ToolId | null } }
|
||||
| { type: 'SET_TOOL_AND_WORKBENCH'; payload: { toolId: ToolId | null; workbench: WorkbenchType } }
|
||||
| { type: 'SET_MODE'; payload: { mode: ModeType } }
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
|
||||
| { type: 'SET_SELECTED_TOOL'; payload: { toolKey: string | null } };
|
||||
|
||||
// Navigation reducer
|
||||
const navigationReducer = (state: NavigationContextState, action: NavigationAction): NavigationContextState => {
|
||||
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
|
||||
switch (action.type) {
|
||||
case 'SET_WORKBENCH':
|
||||
return { ...state, workbench: action.payload.workbench };
|
||||
|
||||
case 'SET_SELECTED_TOOL':
|
||||
return { ...state, selectedTool: action.payload.toolId };
|
||||
|
||||
case 'SET_TOOL_AND_WORKBENCH':
|
||||
return {
|
||||
...state,
|
||||
selectedTool: action.payload.toolId,
|
||||
workbench: action.payload.workbench
|
||||
};
|
||||
|
||||
case 'SET_MODE':
|
||||
return { ...state, currentMode: action.payload.mode };
|
||||
|
||||
case 'SET_UNSAVED_CHANGES':
|
||||
return { ...state, hasUnsavedChanges: action.payload.hasChanges };
|
||||
|
||||
|
||||
case 'SET_PENDING_NAVIGATION':
|
||||
return { ...state, pendingNavigation: action.payload.navigationFn };
|
||||
|
||||
|
||||
case 'SHOW_NAVIGATION_WARNING':
|
||||
return { ...state, showNavigationWarning: action.payload.show };
|
||||
|
||||
|
||||
case 'SET_SELECTED_TOOL':
|
||||
return { ...state, selectedToolKey: action.payload.toolKey };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: NavigationContextState = {
|
||||
workbench: getDefaultWorkbench(),
|
||||
selectedTool: null,
|
||||
const initialState: NavigationState = {
|
||||
currentMode: getDefaultMode(),
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false
|
||||
showNavigationWarning: false,
|
||||
selectedToolKey: null
|
||||
};
|
||||
|
||||
// Navigation context actions interface
|
||||
export interface NavigationContextActions {
|
||||
setWorkbench: (workbench: WorkbenchType) => void;
|
||||
setSelectedTool: (toolId: ToolId | null) => void;
|
||||
setToolAndWorkbench: (toolId: ToolId | null, workbench: WorkbenchType) => void;
|
||||
setMode: (mode: ModeType) => void;
|
||||
setWorkbench: (mode: ModeType) => void; // Alias for V2 compatibility
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
showNavigationWarning: (show: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => void;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
selectTool: (toolKey: string) => void;
|
||||
clearToolSelection: () => void;
|
||||
handleToolSelect: (toolId: string) => void;
|
||||
}
|
||||
|
||||
// Context state values
|
||||
// Split context values
|
||||
export interface NavigationContextStateValue {
|
||||
workbench: WorkbenchType;
|
||||
selectedTool: ToolId | null;
|
||||
currentMode: ModeType;
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
selectedToolKey: string | null;
|
||||
}
|
||||
|
||||
export interface NavigationContextActionsValue {
|
||||
@@ -100,24 +91,19 @@ const NavigationStateContext = createContext<NavigationContextStateValue | undef
|
||||
const NavigationActionsContext = createContext<NavigationContextActionsValue | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export const NavigationProvider: React.FC<{
|
||||
export const NavigationProvider: React.FC<{
|
||||
children: React.ReactNode;
|
||||
enableUrlSync?: boolean;
|
||||
}> = ({ children, enableUrlSync = true }) => {
|
||||
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||
const toolRegistry = useFlatToolRegistry();
|
||||
|
||||
const actions: NavigationContextActions = {
|
||||
setWorkbench: useCallback((workbench: WorkbenchType) => {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
setMode: useCallback((mode: ModeType) => {
|
||||
dispatch({ type: 'SET_MODE', payload: { mode } });
|
||||
}, []),
|
||||
|
||||
setSelectedTool: useCallback((toolId: ToolId | null) => {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolId } });
|
||||
}, []),
|
||||
|
||||
setToolAndWorkbench: useCallback((toolId: ToolId | null, workbench: WorkbenchType) => {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId, workbench } });
|
||||
setWorkbench: useCallback((mode: ModeType) => {
|
||||
dispatch({ type: 'SET_MODE', payload: { mode } });
|
||||
}, []),
|
||||
|
||||
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
||||
@@ -129,67 +115,75 @@ export const NavigationProvider: React.FC<{
|
||||
}, []),
|
||||
|
||||
requestNavigation: useCallback((navigationFn: () => void) => {
|
||||
// If no unsaved changes, navigate immediately
|
||||
if (!state.hasUnsavedChanges) {
|
||||
navigationFn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, store the navigation and show warning
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
}, [state.hasUnsavedChanges]),
|
||||
|
||||
confirmNavigation: useCallback(() => {
|
||||
// Execute pending navigation
|
||||
if (state.pendingNavigation) {
|
||||
state.pendingNavigation();
|
||||
}
|
||||
|
||||
|
||||
// Clear navigation state
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, [state.pendingNavigation]),
|
||||
|
||||
cancelNavigation: useCallback(() => {
|
||||
// Clear navigation without executing
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, []),
|
||||
|
||||
selectTool: useCallback((toolKey: string) => {
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey } });
|
||||
}, []),
|
||||
|
||||
clearToolSelection: useCallback(() => {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
|
||||
}, []),
|
||||
|
||||
handleToolSelect: useCallback((toolId: string) => {
|
||||
// Handle special cases
|
||||
if (toolId === 'allTools') {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Special-case: if tool is a dedicated reader tool, enter reader mode
|
||||
if (toolId === 'read' || toolId === 'view-pdf') {
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the tool in the registry to get its proper workbench
|
||||
|
||||
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
|
||||
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
|
||||
|
||||
// Validate toolId and convert to ToolId type
|
||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
|
||||
}, [toolRegistry])
|
||||
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: toolId } });
|
||||
dispatch({ type: 'SET_MODE', payload: { mode: 'fileEditor' as ModeType } });
|
||||
}, [])
|
||||
};
|
||||
|
||||
const stateValue: NavigationContextStateValue = {
|
||||
workbench: state.workbench,
|
||||
selectedTool: state.selectedTool,
|
||||
currentMode: state.currentMode,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning
|
||||
showNavigationWarning: state.showNavigationWarning,
|
||||
selectedToolKey: state.selectedToolKey
|
||||
};
|
||||
|
||||
const actionsValue: NavigationContextActionsValue = {
|
||||
actions
|
||||
};
|
||||
|
||||
// Enable URL synchronization
|
||||
useNavigationUrlSync(state.currentMode, actions.setMode, enableUrlSync);
|
||||
|
||||
return (
|
||||
<NavigationStateContext.Provider value={stateValue}>
|
||||
<NavigationActionsContext.Provider value={actionsValue}>
|
||||
@@ -227,7 +221,7 @@ export const useNavigation = () => {
|
||||
export const useNavigationGuard = () => {
|
||||
const state = useNavigationState();
|
||||
const { actions } = useNavigationActions();
|
||||
|
||||
|
||||
return {
|
||||
pendingNavigation: state.pendingNavigation,
|
||||
showNavigationWarning: state.showNavigationWarning,
|
||||
@@ -239,3 +233,13 @@ export const useNavigationGuard = () => {
|
||||
setShowNavigationWarning: actions.showNavigationWarning
|
||||
};
|
||||
};
|
||||
|
||||
// Re-export utility functions from types for backward compatibility
|
||||
export { isValidMode, getDefaultMode, type ModeType } from '../types/navigation';
|
||||
|
||||
// TODO: This will be expanded for URL-based routing system
|
||||
// - URL parsing utilities
|
||||
// - Route definitions
|
||||
// - Navigation hooks with URL sync
|
||||
// - History management
|
||||
// - Breadcrumb restoration from URL params
|
||||
@@ -6,11 +6,9 @@
|
||||
import React, { createContext, useContext, useReducer, useCallback, useMemo } from 'react';
|
||||
import { useToolManagement } from '../hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '../types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy';
|
||||
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
|
||||
import { useNavigationActions, useNavigationState } from './NavigationContext';
|
||||
import { ToolId, isValidToolId } from '../types/toolId';
|
||||
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||
import { getDefaultWorkbench } from '../types/workbench';
|
||||
|
||||
// State interface
|
||||
interface ToolWorkflowState {
|
||||
@@ -85,7 +83,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
setSearchQuery: (query: string) => void;
|
||||
|
||||
// Tool Actions
|
||||
selectTool: (toolId: ToolId | null) => void;
|
||||
selectTool: (toolId: string) => void;
|
||||
clearToolSelection: () => void;
|
||||
|
||||
// Tool Reset Actions
|
||||
@@ -126,7 +124,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
} = useToolManagement();
|
||||
|
||||
// Get selected tool from navigation context
|
||||
const selectedTool = getSelectedTool(navigationState.selectedTool);
|
||||
const selectedTool = getSelectedTool(navigationState.selectedToolKey);
|
||||
|
||||
// UI Action creators
|
||||
const setSidebarsVisible = useCallback((visible: boolean) => {
|
||||
@@ -138,17 +136,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
}, []);
|
||||
|
||||
const setReaderMode = useCallback((mode: boolean) => {
|
||||
if (mode) {
|
||||
actions.setWorkbench('viewer');
|
||||
actions.setSelectedTool('read');
|
||||
}
|
||||
dispatch({ type: 'SET_READER_MODE', payload: mode });
|
||||
}, [actions]);
|
||||
}, []);
|
||||
|
||||
const setPreviewFile = useCallback((file: File | null) => {
|
||||
dispatch({ type: 'SET_PREVIEW_FILE', payload: file });
|
||||
if (file) {
|
||||
actions.setWorkbench('viewer');
|
||||
actions.setMode('viewer');
|
||||
}
|
||||
}, [actions]);
|
||||
|
||||
@@ -178,17 +172,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: string) => {
|
||||
// Set the selected tool and determine the appropriate workbench
|
||||
const validToolId = isValidToolId(toolId) ? toolId : null;
|
||||
actions.setSelectedTool(validToolId);
|
||||
|
||||
// Get the tool from registry to determine workbench
|
||||
const tool = getSelectedTool(toolId);
|
||||
if (tool && tool.workbench) {
|
||||
actions.setWorkbench(tool.workbench);
|
||||
} else {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
actions.handleToolSelect(toolId);
|
||||
|
||||
// Clear search query when selecting a tool
|
||||
setSearchQuery('');
|
||||
@@ -205,13 +189,13 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}
|
||||
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
}, [actions, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
setReaderMode(false);
|
||||
actions.setSelectedTool(null);
|
||||
}, [setLeftPanelView, setReaderMode, actions.setSelectedTool]);
|
||||
actions.clearToolSelection();
|
||||
}, [setLeftPanelView, setReaderMode, actions]);
|
||||
|
||||
const handleReaderToggle = useCallback(() => {
|
||||
setReaderMode(true);
|
||||
@@ -230,20 +214,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
[state.sidebarsVisible, state.readerMode]
|
||||
);
|
||||
|
||||
// URL sync for proper tool navigation
|
||||
useNavigationUrlSync(
|
||||
navigationState.selectedTool,
|
||||
handleToolSelect,
|
||||
handleBackToTools,
|
||||
toolRegistry as ToolRegistry,
|
||||
true
|
||||
);
|
||||
// Enable URL synchronization for tool selection
|
||||
useToolWorkflowUrlSync(navigationState.selectedToolKey, actions.selectTool, actions.clearToolSelection, true);
|
||||
|
||||
// Properly memoized context value
|
||||
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
||||
// State
|
||||
...state,
|
||||
selectedToolKey: navigationState.selectedTool,
|
||||
selectedToolKey: navigationState.selectedToolKey,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
|
||||
@@ -254,8 +232,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
selectTool: actions.setSelectedTool,
|
||||
clearToolSelection: () => actions.setSelectedTool(null),
|
||||
selectTool: actions.selectTool,
|
||||
clearToolSelection: actions.clearToolSelection,
|
||||
|
||||
// Tool Reset Actions
|
||||
registerToolReset,
|
||||
@@ -271,7 +249,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
isPanelVisible,
|
||||
}), [
|
||||
state,
|
||||
navigationState.selectedTool,
|
||||
navigationState.selectedToolKey,
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
setSidebarsVisible,
|
||||
@@ -280,7 +258,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSearchQuery,
|
||||
actions.setSelectedTool,
|
||||
actions.selectTool,
|
||||
actions.clearToolSelection,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
handleToolSelect,
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* FileContext reducer - Pure state management for file operations
|
||||
*/
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
import {
|
||||
FileContextState,
|
||||
FileContextAction,
|
||||
FileId,
|
||||
FileRecord
|
||||
} from '../../types/fileContext';
|
||||
|
||||
@@ -25,6 +25,47 @@ export const initialFileContextState: FileContextState = {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function for consume/undo operations
|
||||
function processFileSwap(
|
||||
state: FileContextState,
|
||||
filesToRemove: FileId[],
|
||||
filesToAdd: FileRecord[]
|
||||
): FileContextState {
|
||||
// Only remove unpinned files
|
||||
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
|
||||
const remainingIds = state.files.ids.filter(id => !unpinnedRemoveIds.includes(id));
|
||||
|
||||
// Remove unpinned files from state
|
||||
const newById = { ...state.files.byId };
|
||||
unpinnedRemoveIds.forEach(id => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Add new files
|
||||
const addedIds: FileId[] = [];
|
||||
filesToAdd.forEach(record => {
|
||||
if (!newById[record.id]) {
|
||||
addedIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...addedIds, ...remainingIds],
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Pure reducer function
|
||||
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||
switch (action.type) {
|
||||
@@ -193,40 +234,12 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
|
||||
case 'CONSUME_FILES': {
|
||||
const { inputFileIds, outputFileRecords } = action.payload;
|
||||
return processFileSwap(state, inputFileIds, outputFileRecords);
|
||||
}
|
||||
|
||||
// Only remove unpinned input files
|
||||
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
|
||||
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
|
||||
|
||||
// Remove unpinned files from state
|
||||
const newById = { ...state.files.byId };
|
||||
unpinnedInputIds.forEach(id => {
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Add output files
|
||||
const outputIds: FileId[] = [];
|
||||
outputFileRecords.forEach(record => {
|
||||
if (!newById[record.id]) {
|
||||
outputIds.push(record.id);
|
||||
newById[record.id] = record;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: [...remainingIds, ...outputIds],
|
||||
byId: newById
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds
|
||||
}
|
||||
};
|
||||
case 'UNDO_CONSUME_FILES': {
|
||||
const { inputFileRecords, outputFileIds } = action.payload;
|
||||
return processFileSwap(state, outputFileIds, inputFileRecords);
|
||||
}
|
||||
|
||||
case 'RESET_CONTEXT': {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
FileId,
|
||||
FileRecord,
|
||||
FileContextAction,
|
||||
FileContextState,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
createFileId,
|
||||
createQuickKey
|
||||
} from '../../types/fileContext';
|
||||
import { FileId, FileMetadata } from '../../types/file';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||
import { FileLifecycleManager } from './lifecycle';
|
||||
import { fileProcessingService } from '../../services/fileProcessingService';
|
||||
@@ -88,12 +89,6 @@ interface AddFileOptions {
|
||||
insertAfterPageId?: string;
|
||||
}
|
||||
|
||||
export interface AddedFile {
|
||||
file: File;
|
||||
id: FileId;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
|
||||
*/
|
||||
@@ -104,13 +99,13 @@ export async function addFiles(
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
lifecycleManager: FileLifecycleManager
|
||||
): Promise<AddedFile[]> {
|
||||
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
||||
// Acquire mutex to prevent race conditions
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
const fileRecords: FileRecord[] = [];
|
||||
const addedFiles: AddedFile[] = [];
|
||||
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
@@ -323,34 +318,28 @@ export async function addFiles(
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
* Helper function to process files into records with thumbnails and metadata
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>
|
||||
): Promise<void> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
|
||||
// Process output files through the 'processed' path to generate thumbnails
|
||||
const outputFileRecords = await Promise.all(
|
||||
outputFiles.map(async (file) => {
|
||||
async function processFilesIntoRecords(
|
||||
files: File[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> {
|
||||
return Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
|
||||
// Generate thumbnail and page count for output file
|
||||
// Generate thumbnail and page count
|
||||
let thumbnail: string | undefined;
|
||||
let pageCount: number = 1;
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
|
||||
if (DEBUG) console.log(`📄 Generating thumbnail for file ${file.name}`);
|
||||
const result = await generateThumbnailWithMetadata(file);
|
||||
thumbnail = result.thumbnail;
|
||||
pageCount = result.pageCount;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
|
||||
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
|
||||
}
|
||||
|
||||
const record = toFileRecord(file, fileId);
|
||||
@@ -362,20 +351,168 @@ export async function consumeFiles(
|
||||
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||
}
|
||||
|
||||
return record;
|
||||
return { record, file, fileId, thumbnail };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to persist files to IndexedDB
|
||||
*/
|
||||
async function persistFilesToIndexedDB(
|
||||
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
|
||||
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
|
||||
): Promise<void> {
|
||||
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => {
|
||||
try {
|
||||
await indexedDB.saveFile(file, fileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist file to IndexedDB:', file.name, error);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume files helper - replace unpinned input files with output files
|
||||
*/
|
||||
export async function consumeFiles(
|
||||
inputFileIds: FileId[],
|
||||
outputFiles: File[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
|
||||
): Promise<FileId[]> {
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||
|
||||
// Process output files with thumbnails and metadata
|
||||
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef);
|
||||
|
||||
// Persist output files to IndexedDB if available
|
||||
if (indexedDB) {
|
||||
await persistFilesToIndexedDB(outputFileRecords, indexedDB);
|
||||
}
|
||||
|
||||
// Dispatch the consume action
|
||||
dispatch({
|
||||
type: 'CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileIds,
|
||||
outputFileRecords
|
||||
outputFileRecords: outputFileRecords.map(({ record }) => record)
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||
|
||||
// Return the output file IDs for undo tracking
|
||||
return outputFileRecords.map(({ fileId }) => fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to restore files to filesRef and manage IndexedDB cleanup
|
||||
*/
|
||||
async function restoreFilesAndCleanup(
|
||||
filesToRestore: Array<{ file: File; record: FileRecord }>,
|
||||
fileIdsToRemove: FileId[],
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
): Promise<void> {
|
||||
// Remove files from filesRef
|
||||
fileIdsToRemove.forEach(id => {
|
||||
if (filesRef.current.has(id)) {
|
||||
if (DEBUG) console.log(`📄 Removing file ${id} from filesRef`);
|
||||
filesRef.current.delete(id);
|
||||
} else {
|
||||
if (DEBUG) console.warn(`📄 File ${id} not found in filesRef`);
|
||||
}
|
||||
});
|
||||
|
||||
// Restore files to filesRef
|
||||
filesToRestore.forEach(({ file, record }) => {
|
||||
if (file && record) {
|
||||
// Validate the file before restoring
|
||||
if (file.size === 0) {
|
||||
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore the file to filesRef
|
||||
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
|
||||
filesRef.current.set(record.id, file);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up IndexedDB
|
||||
if (indexedDB) {
|
||||
const indexedDBPromises = fileIdsToRemove.map(fileId =>
|
||||
indexedDB.deleteFile(fileId).catch(error => {
|
||||
console.error('Failed to delete file from IndexedDB:', fileId, error);
|
||||
throw error; // Re-throw to trigger rollback
|
||||
})
|
||||
);
|
||||
|
||||
// Execute all IndexedDB operations
|
||||
await Promise.all(indexedDBPromises);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes a previous consumeFiles operation by restoring input files and removing output files (unless pinned)
|
||||
*/
|
||||
export async function undoConsumeFiles(
|
||||
inputFiles: File[],
|
||||
inputFileRecords: FileRecord[],
|
||||
outputFileIds: FileId[],
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||
dispatch: React.Dispatch<FileContextAction>,
|
||||
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
|
||||
): Promise<void> {
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`);
|
||||
|
||||
// Validate inputs
|
||||
if (inputFiles.length !== inputFileRecords.length) {
|
||||
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`);
|
||||
}
|
||||
|
||||
// Create a backup of current filesRef state for rollback
|
||||
const backupFilesRef = new Map(filesRef.current);
|
||||
|
||||
try {
|
||||
// Prepare files to restore
|
||||
const filesToRestore = inputFiles.map((file, index) => ({
|
||||
file,
|
||||
record: inputFileRecords[index]
|
||||
}));
|
||||
|
||||
// Restore input files and clean up output files
|
||||
await restoreFilesAndCleanup(
|
||||
filesToRestore,
|
||||
outputFileIds,
|
||||
filesRef,
|
||||
indexedDB
|
||||
);
|
||||
|
||||
// Dispatch the undo action (only if everything else succeeded)
|
||||
dispatch({
|
||||
type: 'UNDO_CONSUME_FILES',
|
||||
payload: {
|
||||
inputFileRecords,
|
||||
outputFileIds
|
||||
}
|
||||
});
|
||||
|
||||
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`);
|
||||
|
||||
} catch (error) {
|
||||
// Rollback filesRef to previous state
|
||||
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);
|
||||
filesRef.current.clear();
|
||||
backupFilesRef.forEach((file, id) => {
|
||||
filesRef.current.set(id, file);
|
||||
});
|
||||
throw error; // Re-throw to let caller handle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
FileContextStateValue,
|
||||
FileContextActionsValue
|
||||
} from './contexts';
|
||||
import { FileRecord } from '../../types/fileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
import { FileId, FileRecord, FileWithId } from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
* Hook for accessing file state (will re-render on any state change)
|
||||
@@ -123,7 +122,7 @@ export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecor
|
||||
/**
|
||||
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||
*/
|
||||
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
export function useAllFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
@@ -136,7 +135,7 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
|
||||
/**
|
||||
* Hook for selected files (optimized for selection-based UI)
|
||||
*/
|
||||
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
export function useSelectedFiles(): { files: FileWithId[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||
const { state, selectors } = useFileState();
|
||||
|
||||
return useMemo(() => ({
|
||||
@@ -165,6 +164,7 @@ export function useFileContext() {
|
||||
// File management
|
||||
addFiles: actions.addFiles,
|
||||
consumeFiles: actions.consumeFiles,
|
||||
undoConsumeFiles: actions.undoConsumeFiles,
|
||||
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented
|
||||
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented
|
||||
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented
|
||||
@@ -187,7 +187,11 @@ export function useFileContext() {
|
||||
isFilePinned: selectors.isFilePinned,
|
||||
|
||||
// Active files
|
||||
activeFiles: selectors.getFiles()
|
||||
activeFiles: selectors.getFiles(),
|
||||
|
||||
// Direct access to actions and selectors (for advanced use cases)
|
||||
actions,
|
||||
selectors
|
||||
}), [state, selectors, actions]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
* File selectors - Pure functions for accessing file state
|
||||
*/
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
import {
|
||||
FileRecord,
|
||||
import {
|
||||
FileId,
|
||||
FileRecord,
|
||||
FileContextState,
|
||||
FileContextSelectors
|
||||
FileContextSelectors,
|
||||
FileWithId,
|
||||
createFileWithId
|
||||
} from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
@@ -17,63 +19,69 @@ export function createFileSelectors(
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): FileContextSelectors {
|
||||
return {
|
||||
getFile: (id: FileId) => filesRef.current.get(id),
|
||||
|
||||
getFile: (id: FileId) => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createFileWithId(file, id) : undefined;
|
||||
},
|
||||
|
||||
getFiles: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
|
||||
return currentIds
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createFileWithId(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as FileWithId[];
|
||||
},
|
||||
|
||||
|
||||
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
||||
|
||||
|
||||
getFileRecords: (ids?: FileId[]) => {
|
||||
const currentIds = ids || stateRef.current.files.ids;
|
||||
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||
},
|
||||
|
||||
|
||||
getAllFileIds: () => stateRef.current.files.ids,
|
||||
|
||||
|
||||
getSelectedFiles: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createFileWithId(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as FileWithId[];
|
||||
},
|
||||
|
||||
|
||||
getSelectedFileRecords: () => {
|
||||
return stateRef.current.ui.selectedFileIds
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
|
||||
// Pinned files selectors
|
||||
getPinnedFileIds: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles);
|
||||
},
|
||||
|
||||
|
||||
getPinnedFiles: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => filesRef.current.get(id))
|
||||
.filter(Boolean) as File[];
|
||||
.map(id => {
|
||||
const file = filesRef.current.get(id);
|
||||
return file ? createFileWithId(file, id) : undefined;
|
||||
})
|
||||
.filter(Boolean) as FileWithId[];
|
||||
},
|
||||
|
||||
|
||||
getPinnedFileRecords: () => {
|
||||
return Array.from(stateRef.current.pinnedFiles)
|
||||
.map(id => stateRef.current.files.byId[id])
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
isFilePinned: (file: File) => {
|
||||
// Find FileId by matching File object properties
|
||||
const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => {
|
||||
const storedFile = filesRef.current.get(id);
|
||||
return storedFile &&
|
||||
storedFile.name === file.name &&
|
||||
storedFile.size === file.size &&
|
||||
storedFile.lastModified === file.lastModified;
|
||||
});
|
||||
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
||||
|
||||
isFilePinned: (file: FileWithId) => {
|
||||
return stateRef.current.pinnedFiles.has(file.fileId);
|
||||
},
|
||||
|
||||
|
||||
// Stable signature for effects - prevents unnecessary re-renders
|
||||
getFilesSignature: () => {
|
||||
return stateRef.current.files.ids
|
||||
@@ -119,12 +127,15 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
|
||||
export function getPrimaryFile(
|
||||
stateRef: React.MutableRefObject<FileContextState>,
|
||||
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||
): { file?: File; record?: FileRecord } {
|
||||
): { file?: FileWithId; record?: FileRecord } {
|
||||
const primaryFileId = stateRef.current.files.ids[0];
|
||||
if (!primaryFileId) return {};
|
||||
|
||||
|
||||
const file = filesRef.current.get(primaryFileId);
|
||||
const record = stateRef.current.files.byId[primaryFileId];
|
||||
|
||||
return {
|
||||
file: filesRef.current.get(primaryFileId),
|
||||
record: stateRef.current.files.byId[primaryFileId]
|
||||
file: file ? createFileWithId(file, primaryFileId) : undefined,
|
||||
record
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
* File lifecycle management - Resource cleanup and memory management
|
||||
*/
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
||||
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -34,12 +33,12 @@ export class FileLifecycleManager {
|
||||
/**
|
||||
* Clean up resources for a specific file (with stateRef access for complete cleanup)
|
||||
*/
|
||||
cleanupFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
|
||||
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Use comprehensive cleanup (same as removeFiles)
|
||||
this.cleanupAllResourcesForFile(fileId, stateRef);
|
||||
|
||||
this.cleanupAllResourcesForFile(fileId as FileId, stateRef);
|
||||
|
||||
// Remove file from state
|
||||
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
|
||||
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId as FileId] } });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -55,12 +54,12 @@ export class FileLifecycleManager {
|
||||
}
|
||||
});
|
||||
this.blobUrls.clear();
|
||||
|
||||
|
||||
// Clear all cleanup timers and generations
|
||||
this.cleanupTimers.forEach(timer => clearTimeout(timer));
|
||||
this.cleanupTimers.clear();
|
||||
this.fileGenerations.clear();
|
||||
|
||||
|
||||
// Clear files ref
|
||||
this.filesRef.current.clear();
|
||||
};
|
||||
@@ -68,7 +67,7 @@ export class FileLifecycleManager {
|
||||
/**
|
||||
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
|
||||
*/
|
||||
scheduleCleanup = (fileId: FileId, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
|
||||
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Cancel existing timer
|
||||
const existingTimer = this.cleanupTimers.get(fileId);
|
||||
if (existingTimer) {
|
||||
@@ -106,7 +105,7 @@ export class FileLifecycleManager {
|
||||
// Clean up all resources for this file
|
||||
this.cleanupAllResourcesForFile(fileId, stateRef);
|
||||
});
|
||||
|
||||
|
||||
// Dispatch removal action once for all files (reducer only updates state)
|
||||
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
||||
};
|
||||
@@ -117,7 +116,7 @@ export class FileLifecycleManager {
|
||||
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
|
||||
// Remove from files ref
|
||||
this.filesRef.current.delete(fileId);
|
||||
|
||||
|
||||
// Cancel cleanup timer and generation
|
||||
const timer = this.cleanupTimers.get(fileId);
|
||||
if (timer) {
|
||||
@@ -125,7 +124,7 @@ export class FileLifecycleManager {
|
||||
this.cleanupTimers.delete(fileId);
|
||||
}
|
||||
this.fileGenerations.delete(fileId);
|
||||
|
||||
|
||||
// Clean up blob URLs from file record if we have access to state
|
||||
if (stateRef) {
|
||||
const record = stateRef.current.files.byId[fileId];
|
||||
@@ -138,7 +137,7 @@ export class FileLifecycleManager {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(record.blobUrl);
|
||||
@@ -146,7 +145,7 @@ export class FileLifecycleManager {
|
||||
// Ignore revocation errors
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Clean up processed file thumbnails
|
||||
if (record.processedFile?.pages) {
|
||||
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
|
||||
@@ -172,13 +171,13 @@ export class FileLifecycleManager {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Additional state guard for rare race conditions
|
||||
if (stateRef && !stateRef.current.files.byId[fileId]) {
|
||||
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
|
||||
};
|
||||
|
||||
@@ -188,4 +187,4 @@ export class FileLifecycleManager {
|
||||
destroy = (): void => {
|
||||
this.cleanupAllFiles();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { type TFunction } from 'i18next';
|
||||
import React from 'react';
|
||||
import { ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
|
||||
import { ToolOperationHook, ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
|
||||
import { BaseToolProps } from '../types/tool';
|
||||
import { WorkbenchType } from '../types/workbench';
|
||||
import { ToolId } from '../types/toolId';
|
||||
import { BaseParameters } from '../types/parameters';
|
||||
|
||||
export enum SubcategoryId {
|
||||
SIGNING = 'signing',
|
||||
@@ -29,6 +28,7 @@ export type ToolRegistryEntry = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<BaseToolProps> | null;
|
||||
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
|
||||
description: string;
|
||||
categoryId: ToolCategoryId;
|
||||
subcategoryId: SubcategoryId;
|
||||
@@ -37,17 +37,13 @@ export type ToolRegistryEntry = {
|
||||
endpoints?: string[];
|
||||
link?: string;
|
||||
type?: string;
|
||||
// URL path for routing (e.g., '/split-pdfs', '/compress-pdf')
|
||||
urlPath?: string;
|
||||
// Workbench type for navigation
|
||||
workbench?: WorkbenchType;
|
||||
// Operation configuration for automation
|
||||
operationConfig?: ToolOperationConfig<any>;
|
||||
// Settings component for automation configuration
|
||||
settingsComponent?: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
export type ToolRegistry = Record<ToolId, ToolRegistryEntry>;
|
||||
export type ToolRegistry = Record<string /* FIX ME: Should be ToolId */, ToolRegistryEntry>;
|
||||
|
||||
export const SUBCATEGORY_ORDER: SubcategoryId[] = [
|
||||
SubcategoryId.SIGNING,
|
||||
@@ -111,30 +107,3 @@ export const getAllApplicationEndpoints = (
|
||||
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
|
||||
return Array.from(new Set([...toolEp, ...convEp]));
|
||||
};
|
||||
|
||||
/**
|
||||
* Default workbench for tools that don't specify one
|
||||
* Returns null to trigger the default case in Workbench component (ToolRenderer)
|
||||
*/
|
||||
export const getDefaultToolWorkbench = (): WorkbenchType => 'fileEditor';
|
||||
|
||||
/**
|
||||
* Get workbench type for a tool
|
||||
*/
|
||||
export const getToolWorkbench = (tool: ToolRegistryEntry): WorkbenchType => {
|
||||
return tool.workbench || getDefaultToolWorkbench();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get URL path for a tool
|
||||
*/
|
||||
export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string => {
|
||||
return tool.urlPath || `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a tool ID exists in the registry
|
||||
*/
|
||||
export const isValidToolId = (toolId: string, registry: ToolRegistry): boolean => {
|
||||
return toolId in registry;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,9 @@ import { useAddPasswordOperation } from './useAddPasswordOperation';
|
||||
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters';
|
||||
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
|
||||
return {
|
||||
...actual,
|
||||
useToolOperation: vi.fn()
|
||||
};
|
||||
});
|
||||
vi.mock('../shared/useToolOperation', () => ({
|
||||
useToolOperation: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock the translation hook
|
||||
const mockT = vi.fn((key: string) => `translated-${key}`);
|
||||
@@ -24,13 +20,13 @@ vi.mock('../../../utils/toolErrorHandler', () => ({
|
||||
}));
|
||||
|
||||
// Import the mocked function
|
||||
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
|
||||
|
||||
|
||||
describe('useAddPasswordOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<AddPasswordFullParameters>;
|
||||
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<AddPasswordFullParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
@@ -46,6 +42,7 @@ describe('useAddPasswordOperation', () => {
|
||||
resetResults: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cancelOperation: vi.fn(),
|
||||
undoOperation: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -95,7 +92,7 @@ describe('useAddPasswordOperation', () => {
|
||||
};
|
||||
|
||||
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const formData = buildFormData(testParameters, testFile);
|
||||
const formData = buildFormData(testParameters, testFile as any /* FIX ME */);
|
||||
|
||||
// Verify the form data contains the file
|
||||
expect(formData.get('fileInput')).toBe(testFile);
|
||||
@@ -116,7 +113,7 @@ describe('useAddPasswordOperation', () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||
{ property: 'multiFileEndpoint' as const, expectedValue: false },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
||||
{ property: 'filePrefix' as const, expectedValue: 'translated-addPassword.filenamePrefix_' },
|
||||
{ property: 'operationType' as const, expectedValue: 'addPassword' }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AddPasswordFullParameters, defaultParameters } from './useAddPasswordParameters';
|
||||
import { defaultParameters as permissionsDefaults } from '../changePermissions/useChangePermissionsParameters';
|
||||
@@ -26,11 +26,11 @@ const fullDefaultParameters: AddPasswordFullParameters = {
|
||||
|
||||
// Static configuration object
|
||||
export const addPasswordOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildAddPasswordFormData,
|
||||
operationType: 'addPassword',
|
||||
endpoint: '/api/v1/security/add-password',
|
||||
buildFormData: buildAddPasswordFormData,
|
||||
filePrefix: 'encrypted_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters: fullDefaultParameters,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters';
|
||||
|
||||
@@ -35,11 +35,11 @@ export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, fi
|
||||
|
||||
// Static configuration object
|
||||
export const addWatermarkOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildAddWatermarkFormData,
|
||||
operationType: 'watermark',
|
||||
endpoint: '/api/v1/security/add-watermark',
|
||||
buildFormData: buildAddWatermarkFormData,
|
||||
filePrefix: 'watermarked_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@@ -51,4 +51,4 @@ export const useAddWatermarkOperation = () => {
|
||||
filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { useCallback } from 'react';
|
||||
import { executeAutomationSequence } from '../../../utils/automationExecutor';
|
||||
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
|
||||
@@ -10,7 +10,7 @@ export function useAutomateOperation() {
|
||||
|
||||
const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => {
|
||||
console.log('🚀 Starting automation execution via customProcessor', { params, files });
|
||||
|
||||
|
||||
if (!params.automationConfig) {
|
||||
throw new Error('No automation configuration provided');
|
||||
}
|
||||
@@ -40,9 +40,10 @@ export function useAutomateOperation() {
|
||||
}, [toolRegistry]);
|
||||
|
||||
return useToolOperation<AutomateParameters>({
|
||||
toolType: ToolType.custom,
|
||||
operationType: 'automate',
|
||||
endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor
|
||||
buildFormData: () => new FormData(), // Not used with customProcessor
|
||||
customProcessor,
|
||||
filePrefix: '' // No prefix needed since automation handles naming internally
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,29 +2,29 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
|
||||
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
|
||||
import { ToolRegistry } from '../../../data/toolsTaxonomy';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
|
||||
interface UseAutomationFormProps {
|
||||
mode: AutomationMode;
|
||||
existingAutomation?: AutomationConfig;
|
||||
toolRegistry: ToolRegistry;
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
}
|
||||
|
||||
export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) {
|
||||
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) => {
|
||||
const tool = toolRegistry?.[operation as keyof ToolRegistry] as any;
|
||||
const tool = toolRegistry?.[operation] as any;
|
||||
return tool?.name || t(`tools.${operation}.name`, operation);
|
||||
}, [toolRegistry, t]);
|
||||
|
||||
const getToolDefaultParameters = useCallback((operation: string): Record<string, any> => {
|
||||
const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig;
|
||||
const config = toolRegistry[operation]?.operationConfig;
|
||||
if (config?.defaultParameters) {
|
||||
return { ...config.defaultParameters };
|
||||
}
|
||||
@@ -119,4 +119,4 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
|
||||
getToolName,
|
||||
getToolDefaultParameters
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,9 @@ import { useChangePermissionsOperation } from './useChangePermissionsOperation';
|
||||
import type { ChangePermissionsParameters } from './useChangePermissionsParameters';
|
||||
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
|
||||
return {
|
||||
...actual,
|
||||
useToolOperation: vi.fn()
|
||||
};
|
||||
});
|
||||
vi.mock('../shared/useToolOperation', () => ({
|
||||
useToolOperation: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock the translation hook
|
||||
const mockT = vi.fn((key: string) => `translated-${key}`);
|
||||
@@ -24,12 +20,12 @@ vi.mock('../../../utils/toolErrorHandler', () => ({
|
||||
}));
|
||||
|
||||
// Import the mocked function
|
||||
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
|
||||
|
||||
describe('useChangePermissionsOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<ChangePermissionsParameters>;
|
||||
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<ChangePermissionsParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
@@ -45,6 +41,7 @@ describe('useChangePermissionsOperation', () => {
|
||||
resetResults: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cancelOperation: vi.fn(),
|
||||
undoOperation: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -90,7 +87,7 @@ describe('useChangePermissionsOperation', () => {
|
||||
const buildFormData = callArgs.buildFormData;
|
||||
|
||||
const testFile = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
|
||||
const formData = buildFormData(testParameters, testFile);
|
||||
const formData = buildFormData(testParameters, testFile as any /* FIX ME */);
|
||||
|
||||
// Verify the form data contains the file
|
||||
expect(formData.get('fileInput')).toBe(testFile);
|
||||
@@ -110,7 +107,7 @@ describe('useChangePermissionsOperation', () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||
{ property: 'multiFileEndpoint' as const, expectedValue: false },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
|
||||
{ property: 'filePrefix' as const, expectedValue: 'permissions_' },
|
||||
{ property: 'operationType' as const, expectedValue: 'change-permissions' }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ChangePermissionsParameters, defaultParameters } from './useChangePermissionsParameters';
|
||||
|
||||
@@ -24,11 +24,11 @@ export const buildChangePermissionsFormData = (parameters: ChangePermissionsPara
|
||||
|
||||
// Static configuration object
|
||||
export const changePermissionsOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildChangePermissionsFormData,
|
||||
operationType: 'change-permissions',
|
||||
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
|
||||
buildFormData: buildChangePermissionsFormData,
|
||||
filePrefix: 'permissions_',
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@@ -39,6 +39,6 @@ export const useChangePermissionsOperation = () => {
|
||||
...changePermissionsOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(
|
||||
t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.')
|
||||
),
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { CompressParameters, defaultParameters } from './useCompressParameters';
|
||||
|
||||
@@ -24,11 +24,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File
|
||||
|
||||
// Static configuration object
|
||||
export const compressOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildCompressFormData,
|
||||
operationType: 'compress',
|
||||
endpoint: '/api/v1/misc/compress-pdf',
|
||||
buildFormData: buildCompressFormData,
|
||||
filePrefix: 'compressed_',
|
||||
multiFileEndpoint: false, // Individual API calls per file
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters, defaultParameters } from './useConvertParameters';
|
||||
import { detectFileExtension } from '../../../utils/fileUtils';
|
||||
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
@@ -79,11 +79,6 @@ export const createFileFromResponse = (
|
||||
targetExtension: string
|
||||
): File => {
|
||||
const originalName = originalFileName.split('.')[0];
|
||||
|
||||
if (targetExtension == 'pdfa') {
|
||||
targetExtension = 'pdf';
|
||||
}
|
||||
|
||||
const fallbackFilename = `${originalName}_converted.${targetExtension}`;
|
||||
|
||||
return createFileFromApiResponse(responseData, headers, fallbackFilename);
|
||||
@@ -134,10 +129,11 @@ export const convertProcessor = async (
|
||||
|
||||
// Static configuration object
|
||||
export const convertOperationConfig = {
|
||||
toolType: ToolType.custom,
|
||||
customProcessor: convertProcessor, // Can't use callback version here
|
||||
operationType: 'convert',
|
||||
endpoint: '', // Not used with customProcessor but required
|
||||
buildFormData: buildConvertFormData, // Not used with customProcessor but required
|
||||
filePrefix: 'converted_',
|
||||
customProcessor: convertProcessor,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@@ -162,6 +158,6 @@ export const useConvertOperation = () => {
|
||||
return error.message;
|
||||
}
|
||||
return t("convert.errorConversion", "An error occurred while converting the file.");
|
||||
},
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OCRParameters, defaultParameters } from './useOCRParameters';
|
||||
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation';
|
||||
import { useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { useToolResources } from '../shared/useToolResources';
|
||||
|
||||
@@ -52,7 +52,7 @@ export const buildOCRFormData = (parameters: OCRParameters, file: File): FormDat
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Static response handler for OCR - can be used by automation executor
|
||||
// Static response handler for OCR - can be used by automation executor
|
||||
export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extractZipFiles: (blob: Blob) => Promise<File[]>): Promise<File[]> => {
|
||||
const headBuf = await blob.slice(0, 8).arrayBuffer();
|
||||
const head = new TextDecoder().decode(new Uint8Array(headBuf));
|
||||
@@ -94,11 +94,11 @@ export const ocrResponseHandler = async (blob: Blob, originalFiles: File[], extr
|
||||
|
||||
// Static configuration object (without t function dependencies)
|
||||
export const ocrOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildOCRFormData,
|
||||
operationType: 'ocr',
|
||||
endpoint: '/api/v1/misc/ocr-pdf',
|
||||
buildFormData: buildOCRFormData,
|
||||
filePrefix: 'ocr_',
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
|
||||
|
||||
@@ -12,11 +12,11 @@ export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificate
|
||||
|
||||
// Static configuration object
|
||||
export const removeCertificateSignOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRemoveCertificateSignFormData,
|
||||
operationType: 'remove-certificate-sign',
|
||||
endpoint: '/api/v1/security/remove-cert-sign',
|
||||
buildFormData: buildRemoveCertificateSignFormData,
|
||||
filePrefix: 'unsigned_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@@ -28,4 +28,4 @@ export const useRemoveCertificateSignOperation = () => {
|
||||
filePrefix: t('removeCertSign.filenamePrefix', 'unsigned') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('removeCertSign.error.failed', 'An error occurred while removing certificate signatures.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -3,13 +3,10 @@ import { renderHook } from '@testing-library/react';
|
||||
import { useRemovePasswordOperation } from './useRemovePasswordOperation';
|
||||
import type { RemovePasswordParameters } from './useRemovePasswordParameters';
|
||||
|
||||
vi.mock('../shared/useToolOperation', async () => {
|
||||
const actual = await vi.importActual('../shared/useToolOperation'); // Need to keep ToolType etc.
|
||||
return {
|
||||
...actual,
|
||||
useToolOperation: vi.fn()
|
||||
};
|
||||
});
|
||||
// Mock the useToolOperation hook
|
||||
vi.mock('../shared/useToolOperation', () => ({
|
||||
useToolOperation: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock the translation hook
|
||||
const mockT = vi.fn((key: string) => `translated-${key}`);
|
||||
@@ -23,12 +20,12 @@ vi.mock('../../../utils/toolErrorHandler', () => ({
|
||||
}));
|
||||
|
||||
// Import the mocked function
|
||||
import { SingleFileToolOperationConfig, ToolOperationHook, ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../shared/useToolOperation';
|
||||
|
||||
describe('useRemovePasswordOperation', () => {
|
||||
const mockUseToolOperation = vi.mocked(useToolOperation);
|
||||
|
||||
const getToolConfig = () => mockUseToolOperation.mock.calls[0][0] as SingleFileToolOperationConfig<RemovePasswordParameters>;
|
||||
const getToolConfig = (): ToolOperationConfig<RemovePasswordParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<RemovePasswordParameters>;
|
||||
|
||||
const mockToolOperationReturn: ToolOperationHook<unknown> = {
|
||||
files: [],
|
||||
@@ -44,6 +41,7 @@ describe('useRemovePasswordOperation', () => {
|
||||
resetResults: vi.fn(),
|
||||
clearError: vi.fn(),
|
||||
cancelOperation: vi.fn(),
|
||||
undoOperation: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -94,7 +92,7 @@ describe('useRemovePasswordOperation', () => {
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ property: 'toolType' as const, expectedValue: ToolType.singleFile },
|
||||
{ property: 'multiFileEndpoint' as const, expectedValue: false },
|
||||
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/remove-password' },
|
||||
{ property: 'filePrefix' as const, expectedValue: 'translated-removePassword.filenamePrefix_' },
|
||||
{ property: 'operationType' as const, expectedValue: 'removePassword' }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemovePasswordParameters, defaultParameters } from './useRemovePasswordParameters';
|
||||
|
||||
@@ -13,11 +13,11 @@ export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters
|
||||
|
||||
// Static configuration object
|
||||
export const removePasswordOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRemovePasswordFormData,
|
||||
operationType: 'removePassword',
|
||||
endpoint: '/api/v1/security/remove-password',
|
||||
buildFormData: buildRemovePasswordFormData,
|
||||
filePrefix: 'decrypted_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RepairParameters, defaultParameters } from './useRepairParameters';
|
||||
|
||||
@@ -12,11 +12,11 @@ export const buildRepairFormData = (parameters: RepairParameters, file: File): F
|
||||
|
||||
// Static configuration object
|
||||
export const repairOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildRepairFormData,
|
||||
operationType: 'repair',
|
||||
endpoint: '/api/v1/misc/repair',
|
||||
buildFormData: buildRepairFormData,
|
||||
filePrefix: 'repaired_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@@ -28,4 +28,4 @@ export const useRepairOperation = () => {
|
||||
filePrefix: t('repair.filenamePrefix', 'repaired') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('repair.error.failed', 'An error occurred while repairing the PDF.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SanitizeParameters, defaultParameters } from './useSanitizeParameters';
|
||||
|
||||
@@ -21,10 +21,9 @@ export const buildSanitizeFormData = (parameters: SanitizeParameters, file: File
|
||||
|
||||
// Static configuration object
|
||||
export const sanitizeOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildSanitizeFormData,
|
||||
operationType: 'sanitize',
|
||||
endpoint: '/api/v1/security/sanitize-pdf',
|
||||
buildFormData: buildSanitizeFormData,
|
||||
filePrefix: 'sanitized_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
|
||||
import { BaseToolProps } from '../../../types/tool';
|
||||
import { ToolOperationHook } from './useToolOperation';
|
||||
import { BaseParametersHook } from './useBaseParameters';
|
||||
import { FileWithId } from '../../../types/fileContext';
|
||||
|
||||
interface BaseToolReturn<TParams> {
|
||||
// File management
|
||||
selectedFiles: File[];
|
||||
selectedFiles: FileWithId[];
|
||||
|
||||
// Tool-specific hooks
|
||||
params: BaseParametersHook<TParams>;
|
||||
@@ -21,6 +22,7 @@ interface BaseToolReturn<TParams> {
|
||||
handleExecute: () => Promise<void>;
|
||||
handleThumbnailClick: (file: File) => void;
|
||||
handleSettingsReset: () => void;
|
||||
handleUndo: () => Promise<void>;
|
||||
|
||||
// Standard computed state
|
||||
hasFiles: boolean;
|
||||
@@ -88,6 +90,11 @@ export function useBaseTool<TParams>(
|
||||
onPreviewFile?.(null);
|
||||
}, [operation, onPreviewFile]);
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
}, [operation, onPreviewFile]);
|
||||
|
||||
// Standard computed state
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
@@ -109,6 +116,7 @@ export function useBaseTool<TParams>(
|
||||
handleExecute,
|
||||
handleThumbnailClick,
|
||||
handleSettingsReset,
|
||||
handleUndo,
|
||||
|
||||
// State
|
||||
hasFiles,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
@@ -6,19 +6,12 @@ import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { createOperation } from '../../../utils/toolOperationTracker';
|
||||
import { FileWithId, extractFiles, FileId, FileRecord } from '../../../types/fileContext';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { FileId } from '../../../types/file';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ProcessingProgress, ResponseHandler };
|
||||
|
||||
export enum ToolType {
|
||||
singleFile,
|
||||
multiFile,
|
||||
custom,
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for tool operations defining processing behavior and API integration.
|
||||
*
|
||||
@@ -27,16 +20,45 @@ export enum ToolType {
|
||||
* 2. Multi-file tools: multiFileEndpoint: true, single API call with all files
|
||||
* 3. Complex tools: customProcessor handles all processing logic
|
||||
*/
|
||||
interface BaseToolOperationConfig<TParams> {
|
||||
export interface ToolOperationConfig<TParams = void> {
|
||||
/** Operation identifier for tracking and logging */
|
||||
operationType: string;
|
||||
|
||||
/**
|
||||
* API endpoint for the operation. Can be static string or function for dynamic routing.
|
||||
* Not used when customProcessor is provided.
|
||||
*/
|
||||
endpoint: string | ((params: TParams) => string);
|
||||
|
||||
/**
|
||||
* Builds FormData for API request. Signature determines processing approach:
|
||||
* - (params, file: File) => FormData: Single-file processing
|
||||
* - (params, files: File[]) => FormData: Multi-file processing
|
||||
* Not used when customProcessor is provided.
|
||||
*/
|
||||
buildFormData: ((params: TParams, file: File) => FormData) | ((params: TParams, files: File[]) => FormData); /* FIX ME */
|
||||
|
||||
/** Prefix added to processed filenames (e.g., 'compressed_', 'split_') */
|
||||
filePrefix: string;
|
||||
|
||||
/**
|
||||
* Whether this tool uses backends that accept MultipartFile[] arrays.
|
||||
* - true: Single API call with all files (backend uses MultipartFile[])
|
||||
* - false/undefined: Individual API calls per file (backend uses single MultipartFile)
|
||||
* Ignored when customProcessor is provided.
|
||||
*/
|
||||
multiFileEndpoint?: boolean;
|
||||
|
||||
/** How to handle API responses (e.g., ZIP extraction, single file response) */
|
||||
responseHandler?: ResponseHandler;
|
||||
|
||||
/**
|
||||
* Custom processing logic that completely bypasses standard file processing.
|
||||
* When provided, tool handles all API calls, response processing, and file creation.
|
||||
* Use for tools with complex routing logic or non-standard processing requirements.
|
||||
*/
|
||||
customProcessor?: (params: TParams, files: File[]) => Promise<File[]>;
|
||||
|
||||
/** Extract user-friendly error messages from API errors */
|
||||
getErrorMessage?: (error: any) => string;
|
||||
|
||||
@@ -44,49 +66,6 @@ interface BaseToolOperationConfig<TParams> {
|
||||
defaultParameters?: TParams;
|
||||
}
|
||||
|
||||
export interface SingleFileToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
|
||||
/** This tool processes one file at a time. */
|
||||
toolType: ToolType.singleFile;
|
||||
|
||||
/** Builds FormData for API request. */
|
||||
buildFormData: ((params: TParams, file: File) => FormData);
|
||||
|
||||
/** API endpoint for the operation. Can be static string or function for dynamic routing. */
|
||||
endpoint: string | ((params: TParams) => string);
|
||||
|
||||
customProcessor?: undefined;
|
||||
}
|
||||
|
||||
export interface MultiFileToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
|
||||
/** This tool processes multiple files at once. */
|
||||
toolType: ToolType.multiFile;
|
||||
|
||||
/** Builds FormData for API request. */
|
||||
buildFormData: ((params: TParams, files: File[]) => FormData);
|
||||
|
||||
/** API endpoint for the operation. Can be static string or function for dynamic routing. */
|
||||
endpoint: string | ((params: TParams) => string);
|
||||
|
||||
customProcessor?: undefined;
|
||||
}
|
||||
|
||||
export interface CustomToolOperationConfig<TParams> extends BaseToolOperationConfig<TParams> {
|
||||
/** This tool has custom behaviour. */
|
||||
toolType: ToolType.custom;
|
||||
|
||||
buildFormData?: undefined;
|
||||
endpoint?: undefined;
|
||||
|
||||
/**
|
||||
* Custom processing logic that completely bypasses standard file processing.
|
||||
* This tool handles all API calls, response processing, and file creation.
|
||||
* Use for tools with complex routing logic or non-standard processing requirements.
|
||||
*/
|
||||
customProcessor: (params: TParams, files: File[]) => Promise<File[]>;
|
||||
}
|
||||
|
||||
export type ToolOperationConfig<TParams = void> = SingleFileToolOperationConfig<TParams> | MultiFileToolOperationConfig<TParams> | CustomToolOperationConfig<TParams>;
|
||||
|
||||
/**
|
||||
* Complete tool operation interface with execution capability
|
||||
*/
|
||||
@@ -103,10 +82,11 @@ export interface ToolOperationHook<TParams = void> {
|
||||
progress: ProcessingProgress | null;
|
||||
|
||||
// Actions
|
||||
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>;
|
||||
executeOperation: (params: TParams, selectedFiles: FileWithId[]) => Promise<void>;
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
cancelOperation: () => void;
|
||||
undoOperation: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
@@ -124,20 +104,27 @@ export { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
* @param config - Tool operation configuration
|
||||
* @returns Hook interface with state and execution methods
|
||||
*/
|
||||
export const useToolOperation = <TParams>(
|
||||
export const useToolOperation = <TParams = void>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext();
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||
|
||||
// Track last operation for undo functionality
|
||||
const lastOperationRef = useRef<{
|
||||
inputFiles: File[];
|
||||
inputFileRecords: FileRecord[];
|
||||
outputFileIds: FileId[];
|
||||
} | null>(null);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
params: TParams,
|
||||
selectedFiles: File[]
|
||||
selectedFiles: FileWithId[]
|
||||
): Promise<void> => {
|
||||
// Validation
|
||||
if (selectedFiles.length === 0) {
|
||||
@@ -151,9 +138,6 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup operation tracking
|
||||
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
// Reset state
|
||||
actions.setLoading(true);
|
||||
@@ -164,28 +148,17 @@ export const useToolOperation = <TParams>(
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
|
||||
switch (config.toolType) {
|
||||
case ToolType.singleFile:
|
||||
// Individual file processing - separate API call per file
|
||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||
endpoint: config.endpoint,
|
||||
buildFormData: config.buildFormData,
|
||||
filePrefix: config.filePrefix,
|
||||
responseHandler: config.responseHandler
|
||||
};
|
||||
processedFiles = await processFiles(
|
||||
params,
|
||||
validFiles,
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus
|
||||
);
|
||||
break;
|
||||
const validRegularFiles = extractFiles(validFiles);
|
||||
|
||||
case ToolType.multiFile:
|
||||
if (config.customProcessor) {
|
||||
actions.setStatus('Processing files...');
|
||||
processedFiles = await config.customProcessor(params, validRegularFiles);
|
||||
} else {
|
||||
// Use explicit multiFileEndpoint flag to determine processing approach
|
||||
if (config.multiFileEndpoint) {
|
||||
// Multi-file processing - single API call with all files
|
||||
actions.setStatus('Processing files...');
|
||||
const formData = config.buildFormData(params, validFiles);
|
||||
const formData = (config.buildFormData as (params: TParams, files: File[]) => FormData)(params, validRegularFiles);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
|
||||
const response = await axios.post(endpoint, formData, { responseType: 'blob' });
|
||||
@@ -197,7 +170,7 @@ export const useToolOperation = <TParams>(
|
||||
} 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 originalFileName = validRegularFiles[0]?.name || 'document.pdf';
|
||||
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
|
||||
processedFiles = [singleFile];
|
||||
} else {
|
||||
@@ -209,12 +182,22 @@ export const useToolOperation = <TParams>(
|
||||
processedFiles = await extractAllZipFiles(response.data);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ToolType.custom:
|
||||
actions.setStatus('Processing files...');
|
||||
processedFiles = await config.customProcessor(params, validFiles);
|
||||
break;
|
||||
} else {
|
||||
// Individual file processing - separate API call per file
|
||||
const apiCallsConfig: ApiCallsConfig<TParams> = {
|
||||
endpoint: config.endpoint,
|
||||
buildFormData: config.buildFormData as (params: TParams, file: File) => FormData,
|
||||
filePrefix: config.filePrefix,
|
||||
responseHandler: config.responseHandler
|
||||
};
|
||||
processedFiles = await processFiles(
|
||||
params,
|
||||
validRegularFiles,
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (processedFiles.length > 0) {
|
||||
@@ -232,22 +215,45 @@ export const useToolOperation = <TParams>(
|
||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||
|
||||
// Replace input files with processed files (consumeFiles handles pinning)
|
||||
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as FileId[];
|
||||
await consumeFiles(inputFileIds, processedFiles);
|
||||
const inputFileIds: FileId[] = [];
|
||||
const inputFileRecords: FileRecord[] = [];
|
||||
|
||||
// Build parallel arrays of IDs and records for undo tracking
|
||||
for (const file of validFiles) {
|
||||
const fileId = findFileId(file);
|
||||
if (fileId) {
|
||||
const record = selectors.getFileRecord(fileId);
|
||||
if (record) {
|
||||
inputFileIds.push(fileId);
|
||||
inputFileRecords.push(record);
|
||||
} else {
|
||||
console.warn(`No file record found for file: ${file.name}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`No file ID found for file: ${file.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
inputFiles: validFiles, // Keep original File objects for undo
|
||||
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues
|
||||
outputFileIds
|
||||
};
|
||||
|
||||
markOperationApplied(fileId, operationId);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
actions.setStatus('');
|
||||
markOperationFailed(fileId, operationId, errorMessage);
|
||||
} finally {
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
cancelApiCalls();
|
||||
@@ -259,8 +265,65 @@ export const useToolOperation = <TParams>(
|
||||
const resetResults = useCallback(() => {
|
||||
cleanupBlobUrls();
|
||||
actions.resetResults();
|
||||
// Clear undo data when results are reset to prevent memory leaks
|
||||
lastOperationRef.current = null;
|
||||
}, [cleanupBlobUrls, actions]);
|
||||
|
||||
// Cleanup on unmount to prevent memory leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
lastOperationRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const undoOperation = useCallback(async () => {
|
||||
if (!lastOperationRef.current) {
|
||||
actions.setError(t('noOperationToUndo', 'No operation to undo'));
|
||||
return;
|
||||
}
|
||||
|
||||
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current;
|
||||
|
||||
// Validate that we have data to undo
|
||||
if (inputFiles.length === 0 || inputFileRecords.length === 0) {
|
||||
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (outputFileIds.length === 0) {
|
||||
actions.setError(t('noFilesToUndo', 'Cannot undo: no files were processed in the last operation'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Undo the consume operation
|
||||
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds);
|
||||
|
||||
// Clear results and operation tracking
|
||||
resetResults();
|
||||
lastOperationRef.current = null;
|
||||
|
||||
// Show success message
|
||||
actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
|
||||
|
||||
} catch (error: any) {
|
||||
let errorMessage = extractErrorMessage(error);
|
||||
|
||||
// Provide more specific error messages based on error type
|
||||
if (error.message?.includes('Mismatch between input files')) {
|
||||
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
|
||||
} else if (error.message?.includes('IndexedDB')) {
|
||||
errorMessage = t('undoStorageError', 'Undo completed but some files could not be saved to storage');
|
||||
} else if (error.name === 'QuotaExceededError') {
|
||||
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
|
||||
}
|
||||
|
||||
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
|
||||
|
||||
// Don't clear the operation data if undo failed - user might want to try again
|
||||
}
|
||||
}, [undoConsumeFiles, resetResults, actions, t]);
|
||||
|
||||
return {
|
||||
// State
|
||||
files: state.files,
|
||||
@@ -277,6 +340,7 @@ export const useToolOperation = <TParams>(
|
||||
executeOperation,
|
||||
resetResults,
|
||||
clearError: actions.clearError,
|
||||
cancelOperation
|
||||
cancelOperation,
|
||||
undoOperation
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
|
||||
|
||||
@@ -12,11 +12,11 @@ export const buildSingleLargePageFormData = (parameters: SingleLargePageParamete
|
||||
|
||||
// Static configuration object
|
||||
export const singleLargePageOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildSingleLargePageFormData,
|
||||
operationType: 'single-large-page',
|
||||
endpoint: '/api/v1/general/pdf-to-single-page',
|
||||
buildFormData: buildSingleLargePageFormData,
|
||||
filePrefix: 'single_page_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@@ -28,4 +28,4 @@ export const useSingleLargePageOperation = () => {
|
||||
filePrefix: t('pdfToSinglePage.filenamePrefix', 'single_page') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('pdfToSinglePage.error.failed', 'An error occurred while converting to single page.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation, ToolOperationConfig } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { SplitParameters, defaultParameters } from './useSplitParameters';
|
||||
import { SPLIT_MODES } from '../../../constants/splitConstants';
|
||||
import { useToolResources } from '../shared/useToolResources';
|
||||
|
||||
// Static functions that can be used by both the hook and automation executor
|
||||
export const buildSplitFormData = (parameters: SplitParameters, file: File): FormData => {
|
||||
export const buildSplitFormData = (parameters: SplitParameters, selectedFiles: File[]): FormData => {
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("fileInput", file);
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
|
||||
switch (parameters.mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
@@ -57,30 +57,19 @@ export const getSplitEndpoint = (parameters: SplitParameters): string => {
|
||||
|
||||
// Static configuration object
|
||||
export const splitOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildSplitFormData,
|
||||
operationType: 'splitPdf',
|
||||
endpoint: getSplitEndpoint,
|
||||
buildFormData: buildSplitFormData,
|
||||
filePrefix: 'split_',
|
||||
multiFileEndpoint: true, // Single API call with all files
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useSplitOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
const { extractZipFiles } = useToolResources();
|
||||
|
||||
// Custom response handler that extracts ZIP files
|
||||
// Can't add to exported config because it requires access to the zip code so must be part of the hook
|
||||
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => {
|
||||
// Split operations return ZIP files with multiple PDF pages
|
||||
return await extractZipFiles(blob);
|
||||
}, [extractZipFiles]);
|
||||
|
||||
const splitConfig: ToolOperationConfig<SplitParameters> = {
|
||||
return useToolOperation<SplitParameters>({
|
||||
...splitOperationConfig,
|
||||
responseHandler,
|
||||
getErrorMessage: createStandardErrorHandler(t('split.error.failed', 'An error occurred while splitting the PDF.'))
|
||||
};
|
||||
|
||||
return useToolOperation(splitConfig);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
|
||||
|
||||
@@ -12,11 +12,11 @@ export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters
|
||||
|
||||
// Static configuration object
|
||||
export const unlockPdfFormsOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildUnlockPdfFormsFormData,
|
||||
operationType: 'unlock-pdf-forms',
|
||||
endpoint: '/api/v1/misc/unlock-pdf-forms',
|
||||
buildFormData: buildUnlockPdfFormsFormData,
|
||||
filePrefix: 'unlocked_forms_', // Will be overridden in hook with translation
|
||||
multiFileEndpoint: false,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
@@ -28,4 +28,4 @@ export const useUnlockPdfFormsOperation = () => {
|
||||
filePrefix: t('unlockPDFForms.filenamePrefix', 'unlocked_forms') + '_',
|
||||
getErrorMessage: createStandardErrorHandler(t('unlockPDFForms.error.failed', 'An error occurred while unlocking PDF forms.'))
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CookieConsent: {
|
||||
run: (config: any) => void;
|
||||
show: (show?: boolean) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface CookieConsentConfig {
|
||||
analyticsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConfig = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyticsEnabled) {
|
||||
console.log('Cookie consent not enabled - analyticsEnabled is false');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double initialization
|
||||
if (window.CookieConsent) {
|
||||
setIsInitialized(true);
|
||||
// Force show the modal if it exists but isn't visible
|
||||
setTimeout(() => {
|
||||
window.CookieConsent.show();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the cookie consent CSS files first
|
||||
const mainCSS = document.createElement('link');
|
||||
mainCSS.rel = 'stylesheet';
|
||||
mainCSS.href = '/css/cookieconsent.css';
|
||||
document.head.appendChild(mainCSS);
|
||||
|
||||
const customCSS = document.createElement('link');
|
||||
customCSS.rel = 'stylesheet';
|
||||
customCSS.href = '/css/cookieconsentCustomisation.css';
|
||||
document.head.appendChild(customCSS);
|
||||
|
||||
// Load the cookie consent library
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/thirdParty/cookieconsent.umd.js';
|
||||
script.onload = () => {
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
|
||||
// Detect current theme and set appropriate mode
|
||||
const detectTheme = () => {
|
||||
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
const hasLightClass = document.documentElement.classList.contains('light');
|
||||
const hasDarkClass = document.documentElement.classList.contains('dark');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
// Priority: Mantine attribute > CSS classes > system preference
|
||||
let isDarkMode = false;
|
||||
|
||||
if (mantineScheme) {
|
||||
isDarkMode = mantineScheme === 'dark';
|
||||
} else if (hasLightClass) {
|
||||
isDarkMode = false;
|
||||
} else if (hasDarkClass) {
|
||||
isDarkMode = true;
|
||||
} else {
|
||||
isDarkMode = systemPrefersDark;
|
||||
}
|
||||
|
||||
// Always explicitly set or remove the class
|
||||
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
|
||||
|
||||
return isDarkMode;
|
||||
};
|
||||
|
||||
// Initial theme detection with slight delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
detectTheme();
|
||||
}, 50);
|
||||
|
||||
// Check if CookieConsent is available
|
||||
if (!window.CookieConsent) {
|
||||
console.error('CookieConsent is not available on window object');
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
const themeObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-mantine-color-scheme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
detectTheme();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-mantine-color-scheme', 'class']
|
||||
});
|
||||
|
||||
|
||||
// Initialize cookie consent with full configuration
|
||||
try {
|
||||
window.CookieConsent.run({
|
||||
autoShow: true,
|
||||
hideFromBots: false,
|
||||
guiOptions: {
|
||||
consentModal: {
|
||||
layout: "bar",
|
||||
position: "bottom",
|
||||
equalWeightButtons: true,
|
||||
flipButtons: true
|
||||
},
|
||||
preferencesModal: {
|
||||
layout: "box",
|
||||
position: "right",
|
||||
equalWeightButtons: true,
|
||||
flipButtons: true
|
||||
}
|
||||
},
|
||||
categories: {
|
||||
necessary: {
|
||||
readOnly: true
|
||||
},
|
||||
analytics: {}
|
||||
},
|
||||
language: {
|
||||
default: "en",
|
||||
translations: {
|
||||
en: {
|
||||
consentModal: {
|
||||
title: t('cookieBanner.popUp.title', 'How we use Cookies'),
|
||||
description: t('cookieBanner.popUp.description.1', 'We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you\'ll love.') +
|
||||
"<br>" +
|
||||
t('cookieBanner.popUp.description.2', 'If you\'d rather not, clicking \'No Thanks\' will only enable the essential cookies needed to keep things running smoothly.'),
|
||||
acceptAllBtn: t('cookieBanner.popUp.acceptAllBtn', 'Okay'),
|
||||
acceptNecessaryBtn: t('cookieBanner.popUp.acceptNecessaryBtn', 'No Thanks'),
|
||||
showPreferencesBtn: t('cookieBanner.popUp.showPreferencesBtn', 'Manage preferences'),
|
||||
},
|
||||
preferencesModal: {
|
||||
title: t('cookieBanner.preferencesModal.title', 'Consent Preferences Center'),
|
||||
acceptAllBtn: t('cookieBanner.preferencesModal.acceptAllBtn', 'Accept all'),
|
||||
acceptNecessaryBtn: t('cookieBanner.preferencesModal.acceptNecessaryBtn', 'Reject all'),
|
||||
savePreferencesBtn: t('cookieBanner.preferencesModal.savePreferencesBtn', 'Save preferences'),
|
||||
closeIconLabel: t('cookieBanner.preferencesModal.closeIconLabel', 'Close modal'),
|
||||
serviceCounterLabel: t('cookieBanner.preferencesModal.serviceCounterLabel', 'Service|Services'),
|
||||
sections: [
|
||||
{
|
||||
title: t('cookieBanner.preferencesModal.subtitle', 'Cookie Usage'),
|
||||
description: t('cookieBanner.preferencesModal.description.1', 'Stirling PDF uses cookies and similar technologies to enhance your experience and understand how our tools are used. This helps us improve performance, develop the features you care about, and provide ongoing support to our users.') +
|
||||
"<br><br>" +
|
||||
t('cookieBanner.preferencesModal.description.2', 'Stirling PDF cannot—and will never—track or access the content of the documents you use.') +
|
||||
"<b> " +
|
||||
t('cookieBanner.preferencesModal.description.3', 'Your privacy and trust are at the core of what we do.') +
|
||||
"</b>"
|
||||
},
|
||||
{
|
||||
title: t('cookieBanner.preferencesModal.necessary.title.1', 'Strictly Necessary Cookies') +
|
||||
"<span class=\"pm__badge\">" +
|
||||
t('cookieBanner.preferencesModal.necessary.title.2', 'Always Enabled') +
|
||||
"</span>",
|
||||
description: t('cookieBanner.preferencesModal.necessary.description', 'These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can\'t be turned off.'),
|
||||
linkedCategory: "necessary"
|
||||
},
|
||||
{
|
||||
title: t('cookieBanner.preferencesModal.analytics.title', 'Analytics'),
|
||||
description: t('cookieBanner.preferencesModal.analytics.description', 'These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.'),
|
||||
linkedCategory: "analytics"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Force show after initialization
|
||||
setTimeout(() => {
|
||||
window.CookieConsent.show();
|
||||
|
||||
// Debug: Check if modal elements exist
|
||||
const ccMain = document.getElementById('cc-main');
|
||||
const consentModal = document.querySelector('.cm-wrapper');
|
||||
|
||||
}, 200);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing CookieConsent:', error);
|
||||
}
|
||||
setIsInitialized(true);
|
||||
}, 100); // Small delay to ensure DOM is ready
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load cookie consent library');
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
// Cleanup script and CSS when component unmounts
|
||||
if (document.head.contains(script)) {
|
||||
document.head.removeChild(script);
|
||||
}
|
||||
if (document.head.contains(mainCSS)) {
|
||||
document.head.removeChild(mainCSS);
|
||||
}
|
||||
if (document.head.contains(customCSS)) {
|
||||
document.head.removeChild(customCSS);
|
||||
}
|
||||
};
|
||||
}, [analyticsEnabled, t]);
|
||||
|
||||
const showCookiePreferences = () => {
|
||||
if (isInitialized && window.CookieConsent) {
|
||||
window.CookieConsent.show(true);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
showCookiePreferences
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useFileState, useFileActions } from '../contexts/FileContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileId } from '../types/fileContext';
|
||||
|
||||
export const useFileHandler = () => {
|
||||
const { state } = useFileState(); // Still needed for addStoredFiles
|
||||
@@ -18,16 +18,20 @@ export const useFileHandler = () => {
|
||||
}, [actions.addFiles]);
|
||||
|
||||
// Add stored files preserving their original IDs to prevent session duplicates
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => {
|
||||
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
|
||||
// Filter out files that already exist with the same ID (exact match)
|
||||
const newFiles = filesWithMetadata.filter(({ originalId }) => {
|
||||
return state.files.byId[originalId] === undefined;
|
||||
return state.files.byId[originalId as FileId] === undefined;
|
||||
});
|
||||
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
await actions.addStoredFiles(newFiles, { selectFiles: true });
|
||||
await actions.addStoredFiles(newFiles.map(({file, originalId, metadata}) => ({
|
||||
file,
|
||||
originalId: originalId as FileId,
|
||||
metadata
|
||||
})), { selectFiles: true });
|
||||
}
|
||||
|
||||
|
||||
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
|
||||
}, [state.files.byId, actions.addStoredFiles]);
|
||||
|
||||
@@ -36,4 +40,4 @@ export const useFileHandler = () => {
|
||||
addMultipleFiles,
|
||||
addStoredFiles,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { FileId } from '../types/file';
|
||||
import { FileId } from '../types/fileContext';
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -12,19 +12,19 @@ export const useFileManager = () => {
|
||||
if (!indexedDB) {
|
||||
throw new Error('IndexedDB context not available');
|
||||
}
|
||||
|
||||
|
||||
// Handle drafts differently from regular files
|
||||
if (fileMetadata.isDraft) {
|
||||
// Load draft from the drafts database
|
||||
try {
|
||||
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
|
||||
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
||||
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['drafts'], 'readonly');
|
||||
const store = transaction.objectStore('drafts');
|
||||
const request = store.get(fileMetadata.id);
|
||||
|
||||
|
||||
request.onsuccess = () => {
|
||||
const draft = request.result;
|
||||
if (draft && draft.pdfData) {
|
||||
@@ -37,17 +37,17 @@ export const useFileManager = () => {
|
||||
reject(new Error('Draft data not found'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Regular file loading
|
||||
if (fileMetadata.id) {
|
||||
const file = await indexedDB.loadFile(fileMetadata.id);
|
||||
const file = await indexedDB.loadFile(fileMetadata.id as FileId);
|
||||
if (file) {
|
||||
return file;
|
||||
}
|
||||
@@ -61,14 +61,14 @@ export const useFileManager = () => {
|
||||
if (!indexedDB) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
// Load regular files metadata only
|
||||
const storedFileMetadata = await indexedDB.loadAllMetadata();
|
||||
|
||||
|
||||
// For now, only regular files - drafts will be handled separately in the future
|
||||
const allFiles = storedFileMetadata;
|
||||
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||
|
||||
|
||||
return sortedFiles;
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent files:', error);
|
||||
@@ -87,7 +87,7 @@ export const useFileManager = () => {
|
||||
throw new Error('IndexedDB context not available');
|
||||
}
|
||||
try {
|
||||
await indexedDB.deleteFile(file.id);
|
||||
await indexedDB.deleteFile(file.id as FileId);
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove file:', error);
|
||||
@@ -95,17 +95,17 @@ export const useFileManager = () => {
|
||||
}
|
||||
}, [indexedDB]);
|
||||
|
||||
const storeFile = useCallback(async (file: File, fileId: FileId) => {
|
||||
const storeFile = useCallback(async (file: File, fileId: string) => {
|
||||
if (!indexedDB) {
|
||||
throw new Error('IndexedDB context not available');
|
||||
}
|
||||
try {
|
||||
// Store file with provided UUID from FileContext (thumbnail generated internally)
|
||||
const metadata = await indexedDB.saveFile(file, fileId);
|
||||
const metadata = await indexedDB.saveFile(file, fileId as FileId);
|
||||
|
||||
// Convert file to ArrayBuffer for StoredFile interface compatibility
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
|
||||
// Return StoredFile format for compatibility with old API
|
||||
return {
|
||||
id: fileId,
|
||||
@@ -123,10 +123,10 @@ export const useFileManager = () => {
|
||||
}, [indexedDB]);
|
||||
|
||||
const createFileSelectionHandlers = useCallback((
|
||||
selectedFiles: FileId[],
|
||||
setSelectedFiles: (files: FileId[]) => void
|
||||
selectedFiles: string[],
|
||||
setSelectedFiles: (files: string[]) => void
|
||||
) => {
|
||||
const toggleSelection = (fileId: FileId) => {
|
||||
const toggleSelection = (fileId: string) => {
|
||||
setSelectedFiles(
|
||||
selectedFiles.includes(fileId)
|
||||
? selectedFiles.filter(id => id !== fileId)
|
||||
@@ -138,13 +138,13 @@ export const useFileManager = () => {
|
||||
setSelectedFiles([]);
|
||||
};
|
||||
|
||||
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => void) => {
|
||||
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
// Filter by UUID and convert to File objects
|
||||
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
|
||||
|
||||
|
||||
// Use stored files flow that preserves IDs
|
||||
const filesWithMetadata = await Promise.all(
|
||||
selectedFileObjects.map(async (metadata) => ({
|
||||
@@ -154,7 +154,7 @@ export const useFileManager = () => {
|
||||
}))
|
||||
);
|
||||
onStoredFilesSelect(filesWithMetadata);
|
||||
|
||||
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
console.error('Failed to load selected files:', error);
|
||||
@@ -169,7 +169,7 @@ export const useFileManager = () => {
|
||||
};
|
||||
}, [convertToFile]);
|
||||
|
||||
const touchFile = useCallback(async (id: FileId) => {
|
||||
const touchFile = useCallback(async (id: string) => {
|
||||
if (!indexedDB) {
|
||||
console.warn('IndexedDB context not available for touch operation');
|
||||
return;
|
||||
@@ -177,7 +177,7 @@ export const useFileManager = () => {
|
||||
try {
|
||||
// Update access time - this will be handled by the cache in IndexedDBContext
|
||||
// when the file is loaded, so we can just load it briefly to "touch" it
|
||||
await indexedDB.loadFile(id);
|
||||
await indexedDB.loadFile(id as FileId);
|
||||
} catch (error) {
|
||||
console.error('Failed to touch file:', error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { isFileObject } from '../types/fileContext';
|
||||
|
||||
/**
|
||||
* Hook to convert a File object to { file: File; url: string } format
|
||||
@@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
|
||||
return useMemo(() => {
|
||||
if (!file) return null;
|
||||
|
||||
// Validate that file is a proper File or Blob object
|
||||
if (!(file instanceof File) && !(file instanceof Blob)) {
|
||||
// Validate that file is a proper File, FileWithId, or Blob object
|
||||
if (!isFileObject(file) && !(file instanceof Blob)) {
|
||||
console.warn('useFileWithUrl: Expected File or Blob, got:', file);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
|
||||
import { FileMetadata } from "../types/file";
|
||||
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||
import { FileId } from "../types/fileContext";
|
||||
|
||||
/**
|
||||
* Calculate optimal scale for thumbnail generation
|
||||
@@ -53,7 +54,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
|
||||
// Try to load file from IndexedDB using new context
|
||||
if (file.id && indexedDB) {
|
||||
const loadedFile = await indexedDB.loadFile(file.id);
|
||||
const loadedFile = await indexedDB.loadFile(file.id as FileId);
|
||||
if (!loadedFile) {
|
||||
throw new Error('File not found in IndexedDB');
|
||||
}
|
||||
@@ -70,7 +71,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||
// Save thumbnail to IndexedDB for persistence
|
||||
if (file.id && indexedDB && thumbnail) {
|
||||
try {
|
||||
await indexedDB.updateThumbnail(file.id, thumbnail);
|
||||
await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
export function usePDFProcessor() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -75,7 +76,7 @@ export function usePDFProcessor() {
|
||||
// Create pages without thumbnails initially - load them lazily
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
originalPageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||
import { FileWithId } from '../types/fileContext';
|
||||
|
||||
export interface PdfSignatureDetectionResult {
|
||||
hasDigitalSignatures: boolean;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => {
|
||||
export const usePdfSignatureDetection = (files: FileWithId[]): PdfSignatureDetectionResult => {
|
||||
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
|
||||
import { ToolId } from '../types/toolId';
|
||||
|
||||
// Material UI Icons
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
@@ -10,7 +9,7 @@ import CropIcon from '@mui/icons-material/Crop';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
|
||||
export interface SuggestedTool {
|
||||
id: ToolId;
|
||||
id: string /* FIX ME: Should be ToolId */;
|
||||
title: string;
|
||||
icon: React.ComponentType<any>;
|
||||
navigate: () => void;
|
||||
@@ -33,7 +32,7 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
|
||||
icon: CleaningServicesIcon
|
||||
},
|
||||
{
|
||||
id: 'split',
|
||||
id: 'splitPdf',
|
||||
title: 'Split',
|
||||
icon: CropIcon
|
||||
},
|
||||
@@ -46,16 +45,16 @@ const ALL_SUGGESTED_TOOLS: Omit<SuggestedTool, 'navigate'>[] = [
|
||||
|
||||
export function useSuggestedTools(): SuggestedTool[] {
|
||||
const { actions } = useNavigationActions();
|
||||
const { selectedTool } = useNavigationState();
|
||||
const { selectedToolKey } = useNavigationState();
|
||||
|
||||
return useMemo(() => {
|
||||
// Filter out the current tool
|
||||
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedTool);
|
||||
const filteredTools = ALL_SUGGESTED_TOOLS.filter(tool => tool.id !== selectedToolKey);
|
||||
|
||||
// Add navigation function to each tool
|
||||
return filteredTools.map(tool => ({
|
||||
...tool,
|
||||
navigate: () => actions.setSelectedTool(tool.id)
|
||||
navigate: () => actions.handleToolSelect(tool.id)
|
||||
}));
|
||||
}, [selectedTool, actions]);
|
||||
}, [selectedToolKey, actions]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||
import { FileId } from '../types/file';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
// Request queue to handle concurrent thumbnail requests
|
||||
interface ThumbnailRequest {
|
||||
@@ -36,44 +36,43 @@ async function processRequestQueue() {
|
||||
while (requestQueue.length > 0) {
|
||||
// Sort queue by page number to prioritize visible pages first
|
||||
requestQueue.sort((a, b) => a.pageNumber - b.pageNumber);
|
||||
|
||||
|
||||
// Take a batch of requests (same file only for efficiency)
|
||||
const batchSize = Math.min(BATCH_SIZE, requestQueue.length);
|
||||
const batch = requestQueue.splice(0, batchSize);
|
||||
|
||||
|
||||
// Group by file to process efficiently
|
||||
const fileGroups = new Map<File, ThumbnailRequest[]>();
|
||||
|
||||
|
||||
// First, resolve any cached thumbnails immediately
|
||||
const uncachedRequests: ThumbnailRequest[] = [];
|
||||
|
||||
|
||||
for (const request of batch) {
|
||||
const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId);
|
||||
if (cached) {
|
||||
request.resolve(cached);
|
||||
} else {
|
||||
uncachedRequests.push(request);
|
||||
|
||||
|
||||
if (!fileGroups.has(request.file)) {
|
||||
fileGroups.set(request.file, []);
|
||||
}
|
||||
fileGroups.get(request.file)!.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Process each file group with batch thumbnail generation
|
||||
for (const [file, requests] of fileGroups) {
|
||||
if (requests.length === 0) continue;
|
||||
|
||||
|
||||
try {
|
||||
const pageNumbers = requests.map(req => req.pageNumber);
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
|
||||
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
||||
|
||||
// Use file name as fileId for PDF document caching
|
||||
const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId;
|
||||
|
||||
|
||||
const fileId = createQuickKey(file);
|
||||
|
||||
const results = await thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
arrayBuffer,
|
||||
@@ -84,11 +83,11 @@ async function processRequestQueue() {
|
||||
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Match results back to requests and resolve
|
||||
for (const request of requests) {
|
||||
const result = results.find(r => r.pageNumber === request.pageNumber);
|
||||
|
||||
|
||||
if (result && result.success && result.thumbnail) {
|
||||
thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail);
|
||||
request.resolve(result.thumbnail);
|
||||
@@ -97,7 +96,7 @@ async function processRequestQueue() {
|
||||
request.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error);
|
||||
// Reject all requests in this batch
|
||||
@@ -116,7 +115,7 @@ async function processRequestQueue() {
|
||||
*/
|
||||
export function useThumbnailGeneration() {
|
||||
const generateThumbnails = useCallback(async (
|
||||
fileId: FileId,
|
||||
fileId: string,
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
options: {
|
||||
@@ -158,22 +157,22 @@ export function useThumbnailGeneration() {
|
||||
clearTimeout(batchTimer);
|
||||
batchTimer = null;
|
||||
}
|
||||
|
||||
|
||||
// Clear the queue and active requests
|
||||
requestQueue.length = 0;
|
||||
activeRequests.clear();
|
||||
isProcessingQueue = false;
|
||||
|
||||
|
||||
thumbnailGenerationService.destroy();
|
||||
}, []);
|
||||
|
||||
const clearPDFCacheForFile = useCallback((fileId: FileId) => {
|
||||
const clearPDFCacheForFile = useCallback((fileId: string) => {
|
||||
thumbnailGenerationService.clearPDFCacheForFile(fileId);
|
||||
}, []);
|
||||
|
||||
const requestThumbnail = useCallback(async (
|
||||
pageId: string,
|
||||
file: File,
|
||||
pageId: string,
|
||||
file: File,
|
||||
pageNumber: number
|
||||
): Promise<string | null> => {
|
||||
// Check cache first for immediate return
|
||||
@@ -203,16 +202,16 @@ export function useThumbnailGeneration() {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Schedule batch processing with a small delay to collect more requests
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
}
|
||||
|
||||
|
||||
// Use shorter delay for the first batch (pages 1-50) to show visible content faster
|
||||
const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE);
|
||||
const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY;
|
||||
|
||||
|
||||
batchTimer = window.setTimeout(() => {
|
||||
processRequestQueue().catch(error => {
|
||||
console.error('Error processing thumbnail request queue:', error);
|
||||
@@ -223,7 +222,7 @@ export function useThumbnailGeneration() {
|
||||
|
||||
// Track this request to prevent duplicates
|
||||
activeRequests.set(pageId, requestPromise);
|
||||
|
||||
|
||||
return requestPromise;
|
||||
}, []);
|
||||
|
||||
@@ -237,4 +236,4 @@ export function useThumbnailGeneration() {
|
||||
clearPDFCacheForFile,
|
||||
requestThumbnail
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,19 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
|
||||
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolSelectedFileIds: FileId[];
|
||||
toolSelectedFileIds: string[];
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
setToolSelectedFileIds: (fileIds: FileId[]) => void;
|
||||
setToolSelectedFileIds: (fileIds: string[]) => void;
|
||||
getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const baseRegistry = useFlatToolRegistry();
|
||||
@@ -35,7 +34,7 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
if (endpointsLoading) return true;
|
||||
const endpoints = baseRegistry[toolKey as keyof typeof baseRegistry]?.endpoints || [];
|
||||
const endpoints = baseRegistry[toolKey]?.endpoints || [];
|
||||
return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// src/hooks/useToolUrlRouting.ts
|
||||
// Focused hook for URL <-> tool-key mapping and browser history sync.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export interface UseToolUrlRoutingOpts {
|
||||
/** Currently selected tool key (from context). */
|
||||
selectedToolKey: string | null;
|
||||
/** Registry of available tools (key -> tool metadata). */
|
||||
toolRegistry: Record<string, any> | null | undefined;
|
||||
/** Select a tool (no extra side-effects). */
|
||||
selectTool: (toolKey: string) => void;
|
||||
/** Clear selection. */
|
||||
clearToolSelection: () => void;
|
||||
/** Called once during initialization if URL contains a tool; may trigger UI changes. */
|
||||
onInitSelect?: (toolKey: string) => void;
|
||||
/** Called when navigating via back/forward (popstate). Defaults to selectTool. */
|
||||
onPopStateSelect?: (toolKey: string) => void;
|
||||
/** Optional base path if the app isn't served at "/" (no trailing slash). Default: "" (root). */
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export function useToolUrlRouting(opts: UseToolUrlRoutingOpts) {
|
||||
const {
|
||||
selectedToolKey,
|
||||
toolRegistry,
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
onInitSelect,
|
||||
onPopStateSelect,
|
||||
basePath = '',
|
||||
} = opts;
|
||||
|
||||
// Central slug map; keep here to co-locate routing policy.
|
||||
const urlMap = useMemo(
|
||||
() =>
|
||||
new Map<string, string>([
|
||||
['compress', 'compress-pdf'],
|
||||
['split', 'split-pdf'],
|
||||
['convert', 'convert-pdf'],
|
||||
['ocr', 'ocr-pdf'],
|
||||
['merge', 'merge-pdf'],
|
||||
['rotate', 'rotate-pdf'],
|
||||
]),
|
||||
[]
|
||||
);
|
||||
|
||||
const getToolUrlSlug = useCallback(
|
||||
(toolKey: string) => urlMap.get(toolKey) ?? toolKey,
|
||||
[urlMap]
|
||||
);
|
||||
|
||||
const getToolKeyFromSlug = useCallback(
|
||||
(slug: string) => {
|
||||
for (const [key, value] of urlMap) {
|
||||
if (value === slug) return key;
|
||||
}
|
||||
return slug; // fall back to raw key
|
||||
},
|
||||
[urlMap]
|
||||
);
|
||||
|
||||
// Internal flag to avoid clearing URL on initial mount.
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// Normalize a pathname by stripping basePath and leading slash.
|
||||
const normalizePath = useCallback(
|
||||
(fullPath: string) => {
|
||||
let p = fullPath;
|
||||
if (basePath && p.startsWith(basePath)) {
|
||||
p = p.slice(basePath.length);
|
||||
}
|
||||
if (p.startsWith('/')) p = p.slice(1);
|
||||
return p;
|
||||
},
|
||||
[basePath]
|
||||
);
|
||||
|
||||
// Update URL when tool changes (but not on first paint before any selection happens).
|
||||
useEffect(() => {
|
||||
if (selectedToolKey) {
|
||||
const slug = getToolUrlSlug(selectedToolKey);
|
||||
const newUrl = `${basePath}/${slug}`.replace(/\/+/, '/');
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
setHasInitialized(true);
|
||||
} else if (hasInitialized) {
|
||||
const rootUrl = basePath || '/';
|
||||
window.history.replaceState({}, '', rootUrl);
|
||||
}
|
||||
}, [selectedToolKey, getToolUrlSlug, hasInitialized, basePath]);
|
||||
|
||||
// Initialize from URL when the registry is ready and nothing is selected yet.
|
||||
useEffect(() => {
|
||||
if (!toolRegistry || Object.keys(toolRegistry).length === 0) return;
|
||||
if (selectedToolKey) return; // don't override explicit selection
|
||||
|
||||
const currentPath = normalizePath(window.location.pathname);
|
||||
if (currentPath) {
|
||||
const toolKey = getToolKeyFromSlug(currentPath);
|
||||
if (toolRegistry[toolKey]) {
|
||||
(onInitSelect ?? selectTool)(toolKey);
|
||||
}
|
||||
}
|
||||
}, [toolRegistry, selectedToolKey, getToolKeyFromSlug, selectTool, onInitSelect, normalizePath]);
|
||||
|
||||
// Handle browser back/forward. NOTE: useRef needs an initial value in TS.
|
||||
const popHandlerRef = useRef<((this: Window, ev: PopStateEvent) => any) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
popHandlerRef.current = () => {
|
||||
const path = normalizePath(window.location.pathname);
|
||||
if (path) {
|
||||
const toolKey = getToolKeyFromSlug(path);
|
||||
if (toolRegistry && toolRegistry[toolKey]) {
|
||||
(onPopStateSelect ?? selectTool)(toolKey);
|
||||
return;
|
||||
}
|
||||
}
|
||||
clearToolSelection();
|
||||
};
|
||||
|
||||
const handler = (e: PopStateEvent) => popHandlerRef.current?.call(window, e);
|
||||
window.addEventListener('popstate', handler);
|
||||
return () => window.removeEventListener('popstate', handler);
|
||||
}, [toolRegistry, selectTool, clearToolSelection, getToolKeyFromSlug, onPopStateSelect, normalizePath]);
|
||||
|
||||
// Expose pure helpers if you want them elsewhere (optional).
|
||||
return { getToolUrlSlug, getToolKeyFromSlug };
|
||||
}
|
||||
@@ -1,107 +1,120 @@
|
||||
/**
|
||||
* URL synchronization hooks for tool routing with registry support
|
||||
* URL synchronization hooks for tool routing
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { ToolId } from '../types/toolId';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { ModeType } from '../types/navigation';
|
||||
import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting';
|
||||
import { ToolRegistry } from '../data/toolsTaxonomy';
|
||||
import { firePixel } from '../utils/scarfTracking';
|
||||
|
||||
/**
|
||||
* Hook to sync workbench and tool with URL using registry
|
||||
* Hook to sync navigation mode with URL
|
||||
*/
|
||||
export function useNavigationUrlSync(
|
||||
selectedTool: ToolId | null,
|
||||
handleToolSelect: (toolId: string) => void,
|
||||
clearToolSelection: () => void,
|
||||
registry: ToolRegistry,
|
||||
currentMode: ModeType,
|
||||
setMode: (mode: ModeType) => void,
|
||||
enableSync: boolean = true
|
||||
) {
|
||||
const hasInitialized = useRef(false);
|
||||
const prevSelectedTool = useRef<ToolId | null>(null);
|
||||
// Initialize workbench and tool from URL on mount
|
||||
// Initialize mode from URL on mount
|
||||
useEffect(() => {
|
||||
if (!enableSync) return;
|
||||
|
||||
// Fire pixel for initial page load
|
||||
const currentPath = window.location.pathname;
|
||||
firePixel(currentPath);
|
||||
|
||||
const route = parseToolRoute(registry);
|
||||
if (route.toolId !== selectedTool) {
|
||||
if (route.toolId) {
|
||||
handleToolSelect(route.toolId);
|
||||
} else if (selectedTool !== null) {
|
||||
// Only clear selection if we actually had a tool selected
|
||||
// Don't clear on initial load when selectedTool starts as null
|
||||
clearToolSelection();
|
||||
}
|
||||
|
||||
const route = parseToolRoute();
|
||||
if (route.mode !== currentMode) {
|
||||
setMode(route.mode);
|
||||
}
|
||||
|
||||
hasInitialized.current = true;
|
||||
}, []); // Only run on mount
|
||||
|
||||
// Update URL when tool or workbench changes
|
||||
// Update URL when mode changes
|
||||
useEffect(() => {
|
||||
if (!enableSync) return;
|
||||
|
||||
if (selectedTool) {
|
||||
updateToolRoute(selectedTool, registry, false); // Use pushState for user navigation
|
||||
} else if (prevSelectedTool.current !== null) {
|
||||
// Only clear URL if we had a tool before (user navigated away)
|
||||
// Don't clear on initial load when both current and previous are null
|
||||
if (window.location.pathname !== '/') {
|
||||
clearToolRoute(false); // Use pushState for user navigation
|
||||
}
|
||||
|
||||
if (currentMode === 'pageEditor') {
|
||||
clearToolRoute();
|
||||
} else {
|
||||
updateToolRoute(currentMode, currentMode);
|
||||
}
|
||||
|
||||
prevSelectedTool.current = selectedTool;
|
||||
}, [selectedTool, registry, enableSync]);
|
||||
}, [currentMode, enableSync]);
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
if (!enableSync) return;
|
||||
|
||||
|
||||
const handlePopState = () => {
|
||||
const route = parseToolRoute(registry);
|
||||
if (route.toolId !== selectedTool) {
|
||||
// Fire pixel for back/forward navigation
|
||||
const currentPath = window.location.pathname;
|
||||
firePixel(currentPath);
|
||||
|
||||
if (route.toolId) {
|
||||
handleToolSelect(route.toolId);
|
||||
} else {
|
||||
clearToolSelection();
|
||||
}
|
||||
const route = parseToolRoute();
|
||||
if (route.mode !== currentMode) {
|
||||
setMode(route.mode);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [selectedTool, handleToolSelect, clearToolSelection, registry, enableSync]);
|
||||
}, [currentMode, setMode, enableSync]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to programmatically navigate to tools with registry support
|
||||
* Hook to sync tool workflow with URL
|
||||
*/
|
||||
export function useToolNavigation(registry: ToolRegistry) {
|
||||
const navigateToTool = useCallback((toolId: ToolId) => {
|
||||
updateToolRoute(toolId, registry);
|
||||
export function useToolWorkflowUrlSync(
|
||||
selectedToolKey: string | null,
|
||||
selectTool: (toolKey: string) => void,
|
||||
clearTool: () => void,
|
||||
enableSync: boolean = true
|
||||
) {
|
||||
// Initialize tool from URL on mount
|
||||
useEffect(() => {
|
||||
if (!enableSync) return;
|
||||
|
||||
const route = parseToolRoute();
|
||||
if (route.toolKey && route.toolKey !== selectedToolKey) {
|
||||
selectTool(route.toolKey);
|
||||
} else if (!route.toolKey && selectedToolKey) {
|
||||
clearTool();
|
||||
}
|
||||
}, []); // Only run on mount
|
||||
|
||||
// Update URL when tool changes
|
||||
useEffect(() => {
|
||||
if (!enableSync) return;
|
||||
|
||||
if (selectedToolKey) {
|
||||
const route = parseToolRoute();
|
||||
if (route.toolKey !== selectedToolKey) {
|
||||
updateToolRoute(selectedToolKey as ModeType, selectedToolKey);
|
||||
}
|
||||
}
|
||||
}, [selectedToolKey, enableSync]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current URL route information
|
||||
*/
|
||||
export function useCurrentRoute() {
|
||||
const getCurrentRoute = useCallback(() => {
|
||||
return parseToolRoute();
|
||||
}, []);
|
||||
|
||||
return getCurrentRoute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to programmatically navigate to tools
|
||||
*/
|
||||
export function useToolNavigation() {
|
||||
const navigateToTool = useCallback((toolKey: string) => {
|
||||
updateToolRoute(toolKey as ModeType, toolKey);
|
||||
|
||||
// Dispatch a custom event to notify other components
|
||||
window.dispatchEvent(new CustomEvent('toolNavigation', {
|
||||
detail: { toolId }
|
||||
window.dispatchEvent(new CustomEvent('toolNavigation', {
|
||||
detail: { toolKey }
|
||||
}));
|
||||
}, [registry]);
|
||||
}, []);
|
||||
|
||||
const navigateToHome = useCallback(() => {
|
||||
clearToolRoute();
|
||||
|
||||
|
||||
// Dispatch a custom event to notify other components
|
||||
window.dispatchEvent(new CustomEvent('toolNavigation', {
|
||||
detail: { toolId: null }
|
||||
window.dispatchEvent(new CustomEvent('toolNavigation', {
|
||||
detail: { toolKey: null }
|
||||
}));
|
||||
}, []);
|
||||
|
||||
@@ -109,15 +122,4 @@ export function useToolNavigation(registry: ToolRegistry) {
|
||||
navigateToTool,
|
||||
navigateToHome
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current URL route information with registry support
|
||||
*/
|
||||
export function useCurrentRoute(registry: ToolRegistry) {
|
||||
const getCurrentRoute = useCallback(() => {
|
||||
return parseToolRoute(registry);
|
||||
}, [registry]);
|
||||
|
||||
return getCurrentRoute;
|
||||
}
|
||||
}
|
||||
+1
-42
@@ -1,11 +1,5 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
@@ -17,38 +11,3 @@ code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
--footer-height: 2rem;
|
||||
}
|
||||
|
||||
/* Footer link styling - make buttons and links look identical */
|
||||
.footer-link {
|
||||
color: var(--mantine-color-gray-6);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--mantine-color-blue-8);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.stirling-link {
|
||||
color: var(--mantine-color-blue-6);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.stirling-link:hover {
|
||||
color: var(--mantine-color-blue-8);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
+4
-33
@@ -1,5 +1,4 @@
|
||||
import '@mantine/core/styles.css';
|
||||
import '../vite-env.d.ts';
|
||||
import './index.css'; // Import Tailwind CSS
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
@@ -7,8 +6,6 @@ import { ColorSchemeScript } from '@mantine/core';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import './i18n'; // Initialize i18next
|
||||
import posthog from 'posthog-js';
|
||||
import { PostHogProvider } from 'posthog-js/react';
|
||||
|
||||
// Compute initial color scheme
|
||||
function getInitialScheme(): 'light' | 'dark' {
|
||||
@@ -22,43 +19,17 @@ function getInitialScheme(): 'light' | 'dark' {
|
||||
}
|
||||
}
|
||||
|
||||
posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
|
||||
api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
|
||||
defaults: '2025-05-24',
|
||||
capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
|
||||
debug: false,
|
||||
opt_out_capturing_by_default: false, // We handle opt-out via cookie consent
|
||||
});
|
||||
|
||||
function updatePosthogConsent(){
|
||||
if(typeof(posthog) == "undefined") {
|
||||
return;
|
||||
}
|
||||
const optIn = (window.CookieConsent as any).acceptedCategory('analytics');
|
||||
optIn?
|
||||
posthog.opt_in_capturing() : posthog.opt_out_capturing();
|
||||
|
||||
console.log("Updated analytics consent: ", optIn? "opted in" : "opted out");
|
||||
}
|
||||
|
||||
window.addEventListener("cc:onConsent", updatePosthogConsent);
|
||||
window.addEventListener("cc:onChange", updatePosthogConsent);
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error("Root container missing in index.html");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(container); // Finds the root DOM element
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ColorSchemeScript defaultColorScheme={getInitialScheme()} />
|
||||
<PostHogProvider
|
||||
client={posthog}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</PostHogProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import Workbench from "../components/layout/Workbench";
|
||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||
import RightRail from "../components/shared/RightRail";
|
||||
import FileManager from "../components/FileManager";
|
||||
import Footer from "../components/shared/Footer";
|
||||
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -39,20 +38,17 @@ export default function HomePage() {
|
||||
// Note: File selection limits are now handled directly by individual tools
|
||||
|
||||
return (
|
||||
<div className="h-screen overflow-hidden">
|
||||
<Group
|
||||
align="flex-start"
|
||||
gap={0}
|
||||
h="100%"
|
||||
className="flex-nowrap flex"
|
||||
>
|
||||
<QuickAccessBar
|
||||
ref={quickAccessRef} />
|
||||
<ToolPanel />
|
||||
<Workbench />
|
||||
<RightRail />
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
</Group>
|
||||
</div>
|
||||
<Group
|
||||
align="flex-start"
|
||||
gap={0}
|
||||
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
|
||||
>
|
||||
<QuickAccessBar
|
||||
ref={quickAccessRef} />
|
||||
<ToolPanel />
|
||||
<Workbench />
|
||||
<RightRail />
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { FileHasher } from '../utils/fileHash';
|
||||
import { FileAnalyzer } from './fileAnalyzer';
|
||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
export class EnhancedPDFProcessingService {
|
||||
private static instance: EnhancedPDFProcessingService;
|
||||
@@ -201,7 +202,7 @@ export class EnhancedPDFProcessingService {
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
@@ -251,7 +252,7 @@ export class EnhancedPDFProcessingService {
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
@@ -266,7 +267,7 @@ export class EnhancedPDFProcessingService {
|
||||
// Create placeholder pages for remaining pages
|
||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
rotation: 0,
|
||||
@@ -313,7 +314,7 @@ export class EnhancedPDFProcessingService {
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
@@ -334,7 +335,7 @@ export class EnhancedPDFProcessingService {
|
||||
// Create placeholders for remaining pages
|
||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
@@ -368,7 +369,7 @@ export class EnhancedPDFProcessingService {
|
||||
const pages: PDFPage[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
import { FileId } from '../types/file';
|
||||
|
||||
export interface ProcessedFileMetadata {
|
||||
totalPages: number;
|
||||
@@ -39,7 +38,7 @@ class FileProcessingService {
|
||||
* Process a file to extract metadata, page count, and generate thumbnails
|
||||
* This is the single source of truth for file processing
|
||||
*/
|
||||
async processFile(file: File, fileId: FileId): Promise<FileProcessingResult> {
|
||||
async processFile(file: File, fileId: string): Promise<FileProcessingResult> {
|
||||
// Check if we're already processing this file
|
||||
const existingOperation = this.processingCache.get(fileId);
|
||||
if (existingOperation) {
|
||||
@@ -49,10 +48,10 @@ class FileProcessingService {
|
||||
|
||||
// Create abort controller for this operation
|
||||
const abortController = new AbortController();
|
||||
|
||||
|
||||
// Create processing promise
|
||||
const processingPromise = this.performProcessing(file, fileId, abortController);
|
||||
|
||||
|
||||
// Store operation with abort controller
|
||||
const operation: ProcessingOperation = {
|
||||
promise: processingPromise,
|
||||
@@ -68,7 +67,7 @@ class FileProcessingService {
|
||||
return processingPromise;
|
||||
}
|
||||
|
||||
private async performProcessing(file: File, fileId: FileId, abortController: AbortController): Promise<FileProcessingResult> {
|
||||
private async performProcessing(file: File, fileId: string, abortController: AbortController): Promise<FileProcessingResult> {
|
||||
console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`);
|
||||
|
||||
try {
|
||||
@@ -84,12 +83,12 @@ class FileProcessingService {
|
||||
if (file.type === 'application/pdf') {
|
||||
// Read arrayBuffer once and reuse for both PDF.js and fallback
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
|
||||
// Check for cancellation after async operation
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
|
||||
// Discover page count using PDF.js (most accurate)
|
||||
try {
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
@@ -102,7 +101,7 @@ class FileProcessingService {
|
||||
|
||||
// Clean up immediately
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
|
||||
|
||||
// Check for cancellation after PDF.js processing
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
@@ -117,7 +116,7 @@ class FileProcessingService {
|
||||
try {
|
||||
thumbnailUrl = await generateThumbnailForFile(file);
|
||||
console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`);
|
||||
|
||||
|
||||
// Check for cancellation after thumbnail generation
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Processing cancelled');
|
||||
@@ -142,7 +141,7 @@ class FileProcessingService {
|
||||
};
|
||||
|
||||
console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`);
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
metadata
|
||||
@@ -150,7 +149,7 @@ class FileProcessingService {
|
||||
|
||||
} catch (error) {
|
||||
console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error);
|
||||
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown processing error'
|
||||
@@ -168,14 +167,14 @@ class FileProcessingService {
|
||||
/**
|
||||
* Check if a file is currently being processed
|
||||
*/
|
||||
isProcessing(fileId: FileId): boolean {
|
||||
isProcessing(fileId: string): boolean {
|
||||
return this.processingCache.has(fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel processing for a specific file
|
||||
*/
|
||||
cancelProcessing(fileId: FileId): boolean {
|
||||
cancelProcessing(fileId: string): boolean {
|
||||
const operation = this.processingCache.get(fileId);
|
||||
if (operation) {
|
||||
operation.abortController.abort();
|
||||
@@ -207,4 +206,4 @@ class FileProcessingService {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const fileProcessingService = new FileProcessingService();
|
||||
export const fileProcessingService = new FileProcessingService();
|
||||
@@ -4,11 +4,10 @@
|
||||
* Now uses centralized IndexedDB manager
|
||||
*/
|
||||
|
||||
import { FileId } from '../types/file';
|
||||
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
|
||||
|
||||
export interface StoredFile {
|
||||
id: FileId;
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
@@ -39,7 +38,7 @@ class FileStorageService {
|
||||
/**
|
||||
* Store a file in IndexedDB with external UUID
|
||||
*/
|
||||
async storeFile(file: File, fileId: FileId, thumbnail?: string): Promise<StoredFile> {
|
||||
async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
@@ -89,7 +88,7 @@ class FileStorageService {
|
||||
/**
|
||||
* Retrieve a file from IndexedDB
|
||||
*/
|
||||
async getFile(id: FileId): Promise<StoredFile | null> {
|
||||
async getFile(id: string): Promise<StoredFile | null> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -167,7 +166,7 @@ class FileStorageService {
|
||||
/**
|
||||
* Delete a file from IndexedDB
|
||||
*/
|
||||
async deleteFile(id: FileId): Promise<void> {
|
||||
async deleteFile(id: string): Promise<void> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -183,12 +182,12 @@ class FileStorageService {
|
||||
/**
|
||||
* Update the lastModified timestamp of a file (for most recently used sorting)
|
||||
*/
|
||||
async touchFile(id: FileId): Promise<boolean> {
|
||||
async touchFile(id: string): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
|
||||
const getRequest = store.get(id);
|
||||
getRequest.onsuccess = () => {
|
||||
const file = getRequest.result;
|
||||
@@ -439,9 +438,9 @@ class FileStorageService {
|
||||
* Convert StoredFile to the format expected by FileContext.addStoredFiles()
|
||||
* This is the recommended way to load stored files into FileContext
|
||||
*/
|
||||
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: FileId; metadata: { thumbnail?: string } } {
|
||||
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: string; metadata: { thumbnail?: string } } {
|
||||
const file = this.createFileFromStored(storedFile);
|
||||
|
||||
|
||||
return {
|
||||
file,
|
||||
originalId: storedFile.id,
|
||||
@@ -462,7 +461,7 @@ class FileStorageService {
|
||||
/**
|
||||
* Get file data as ArrayBuffer for streaming/chunked processing
|
||||
*/
|
||||
async getFileData(id: FileId): Promise<ArrayBuffer | null> {
|
||||
async getFileData(id: string): Promise<ArrayBuffer | null> {
|
||||
try {
|
||||
const storedFile = await this.getFile(id);
|
||||
return storedFile ? storedFile.data : null;
|
||||
@@ -475,7 +474,7 @@ class FileStorageService {
|
||||
/**
|
||||
* Create a temporary blob URL that gets revoked automatically
|
||||
*/
|
||||
async createTemporaryBlobUrl(id: FileId): Promise<string | null> {
|
||||
async createTemporaryBlobUrl(id: string): Promise<string | null> {
|
||||
const data = await this.getFileData(id);
|
||||
if (!data) return null;
|
||||
|
||||
@@ -493,7 +492,7 @@ class FileStorageService {
|
||||
/**
|
||||
* Update thumbnail for an existing file
|
||||
*/
|
||||
async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
|
||||
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
||||
import { ProcessingCache } from './processingCache';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
import { createQuickKey } from '../types/fileContext';
|
||||
|
||||
export class PDFProcessingService {
|
||||
private static instance: PDFProcessingService;
|
||||
@@ -113,7 +114,7 @@ export class PDFProcessingService {
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
id: `${createQuickKey(file)}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* High-performance thumbnail generation service using main thread processing
|
||||
*/
|
||||
|
||||
import { FileId } from '../types/file';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
|
||||
interface ThumbnailResult {
|
||||
@@ -33,12 +32,12 @@ interface CachedPDFDocument {
|
||||
|
||||
export class ThumbnailGenerationService {
|
||||
// Session-based thumbnail cache
|
||||
private thumbnailCache = new Map<FileId | string /* FIX ME: Page ID */, CachedThumbnail>();
|
||||
private thumbnailCache = new Map<string, CachedThumbnail>();
|
||||
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
|
||||
private currentCacheSize = 0;
|
||||
|
||||
// PDF document cache to reuse PDF instances and avoid creating multiple workers
|
||||
private pdfDocumentCache = new Map<FileId, CachedPDFDocument>();
|
||||
private pdfDocumentCache = new Map<string, CachedPDFDocument>();
|
||||
private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached
|
||||
|
||||
constructor(private maxWorkers: number = 10) {
|
||||
@@ -48,7 +47,7 @@ export class ThumbnailGenerationService {
|
||||
/**
|
||||
* Get or create a cached PDF document
|
||||
*/
|
||||
private async getCachedPDFDocument(fileId: FileId, pdfArrayBuffer: ArrayBuffer): Promise<any> {
|
||||
private async getCachedPDFDocument(fileId: string, pdfArrayBuffer: ArrayBuffer): Promise<any> {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached) {
|
||||
cached.lastUsed = Date.now();
|
||||
@@ -80,7 +79,7 @@ export class ThumbnailGenerationService {
|
||||
/**
|
||||
* Release a reference to a cached PDF document
|
||||
*/
|
||||
private releasePDFDocument(fileId: FileId): void {
|
||||
private releasePDFDocument(fileId: string): void {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached) {
|
||||
cached.refCount--;
|
||||
@@ -92,7 +91,7 @@ export class ThumbnailGenerationService {
|
||||
* Evict the least recently used PDF document
|
||||
*/
|
||||
private evictLeastRecentlyUsedPDF(): void {
|
||||
let oldestEntry: [FileId, CachedPDFDocument] | null = null;
|
||||
let oldestEntry: [string, CachedPDFDocument] | null = null;
|
||||
let oldestTime = Date.now();
|
||||
|
||||
for (const [key, value] of this.pdfDocumentCache.entries()) {
|
||||
@@ -112,7 +111,7 @@ export class ThumbnailGenerationService {
|
||||
* Generate thumbnails for multiple pages using main thread processing
|
||||
*/
|
||||
async generateThumbnails(
|
||||
fileId: FileId,
|
||||
fileId: string,
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
options: ThumbnailGenerationOptions = {},
|
||||
@@ -122,11 +121,11 @@ export class ThumbnailGenerationService {
|
||||
if (!fileId || typeof fileId !== 'string' || fileId.trim() === '') {
|
||||
throw new Error('generateThumbnails: fileId must be a non-empty string');
|
||||
}
|
||||
|
||||
|
||||
if (!pdfArrayBuffer || pdfArrayBuffer.byteLength === 0) {
|
||||
throw new Error('generateThumbnails: pdfArrayBuffer must not be empty');
|
||||
}
|
||||
|
||||
|
||||
if (!pageNumbers || pageNumbers.length === 0) {
|
||||
throw new Error('generateThumbnails: pageNumbers must not be empty');
|
||||
}
|
||||
@@ -143,7 +142,7 @@ export class ThumbnailGenerationService {
|
||||
* Main thread thumbnail generation with batching for UI responsiveness
|
||||
*/
|
||||
private async generateThumbnailsMainThread(
|
||||
fileId: FileId,
|
||||
fileId: string,
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
scale: number,
|
||||
@@ -151,48 +150,48 @@ export class ThumbnailGenerationService {
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||
): Promise<ThumbnailResult[]> {
|
||||
const pdf = await this.getCachedPDFDocument(fileId, pdfArrayBuffer);
|
||||
|
||||
|
||||
const allResults: ThumbnailResult[] = [];
|
||||
let completed = 0;
|
||||
const batchSize = 3; // Smaller batches for better UI responsiveness
|
||||
|
||||
|
||||
// Process pages in small batches
|
||||
for (let i = 0; i < pageNumbers.length; i += batchSize) {
|
||||
const batch = pageNumbers.slice(i, i + batchSize);
|
||||
|
||||
|
||||
// Process batch sequentially (to avoid canvas conflicts)
|
||||
for (const pageNumber of batch) {
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL('image/jpeg', quality);
|
||||
|
||||
|
||||
allResults.push({ pageNumber, thumbnail, success: true });
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error);
|
||||
allResults.push({
|
||||
pageNumber,
|
||||
thumbnail: '',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
allResults.push({
|
||||
pageNumber,
|
||||
thumbnail: '',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
completed += batch.length;
|
||||
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
@@ -201,16 +200,16 @@ export class ThumbnailGenerationService {
|
||||
thumbnails: allResults.slice(-batch.length).filter(r => r.success)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Yield control to prevent UI blocking
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
|
||||
|
||||
// Release reference to PDF document (don't destroy - keep in cache)
|
||||
this.releasePDFDocument(fileId);
|
||||
|
||||
|
||||
this.cleanupCompletedDocument(fileId);
|
||||
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
@@ -228,7 +227,7 @@ export class ThumbnailGenerationService {
|
||||
|
||||
addThumbnailToCache(pageId: string, thumbnail: string): void {
|
||||
const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
|
||||
|
||||
|
||||
// Enforce cache size limits
|
||||
while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) {
|
||||
this.evictLeastRecentlyUsed();
|
||||
@@ -239,7 +238,7 @@ export class ThumbnailGenerationService {
|
||||
lastUsed: Date.now(),
|
||||
sizeBytes
|
||||
});
|
||||
|
||||
|
||||
this.currentCacheSize += sizeBytes;
|
||||
}
|
||||
|
||||
@@ -285,7 +284,7 @@ export class ThumbnailGenerationService {
|
||||
this.pdfDocumentCache.clear();
|
||||
}
|
||||
|
||||
clearPDFCacheForFile(fileId: FileId): void {
|
||||
clearPDFCacheForFile(fileId: string): void {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached) {
|
||||
pdfWorkerManager.destroyDocument(cached.pdf);
|
||||
@@ -297,7 +296,7 @@ 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: FileId): void {
|
||||
cleanupCompletedDocument(fileId: string): void {
|
||||
const cached = this.pdfDocumentCache.get(fileId);
|
||||
if (cached && cached.refCount <= 0) {
|
||||
pdfWorkerManager.destroyDocument(cached.pdf);
|
||||
@@ -312,4 +311,4 @@ export class ThumbnailGenerationService {
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
export const thumbnailGenerationService = new ThumbnailGenerationService();
|
||||
export const thumbnailGenerationService = new ThumbnailGenerationService();
|
||||
@@ -1,59 +0,0 @@
|
||||
/* Cookie Consent Modal Styling - Ensure proper z-index */
|
||||
|
||||
/* Ensure cookie consent appears above everything */
|
||||
#cc-main {
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
|
||||
/* Additional styling if needed */
|
||||
.cm-wrapper,
|
||||
.pm-wrapper {
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
|
||||
/* Dark mode styling */
|
||||
.cc--darkmode .cm {
|
||||
background: #2d2d2d !important;
|
||||
color: #ffffff !important;
|
||||
border-top: 1px solid #444 !important;
|
||||
}
|
||||
|
||||
.cc--darkmode .pm {
|
||||
background: #2d2d2d !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.cc--darkmode .pm-overlay {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
|
||||
/* Button styling */
|
||||
.cc--darkmode .cm__btn {
|
||||
background: #444 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #666 !important;
|
||||
}
|
||||
|
||||
.cc--darkmode .cm__btn:hover {
|
||||
background: #555 !important;
|
||||
}
|
||||
|
||||
.cc--darkmode .pm__btn {
|
||||
background: #444 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #666 !important;
|
||||
}
|
||||
|
||||
.cc--darkmode .pm__btn:hover {
|
||||
background: #555 !important;
|
||||
}
|
||||
|
||||
/* Ensure ScrollArea doesn't interfere */
|
||||
.mantine-ScrollArea-root {
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
/* Override any potential conflicts */
|
||||
[data-mantine-color-scheme="dark"] #cc-main {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { FileContextProvider } from '../../contexts/FileContext';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
import { createTestFileWithId } from '../utils/testFileHelpers';
|
||||
import { FileWithId } from '../../types/fileContext';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
@@ -29,7 +31,7 @@ vi.mock('../../services/fileStorage', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
return Promise.resolve({
|
||||
id: `mock-id-${file.name}`,
|
||||
id: `mock-uuid-${Math.random().toString(36).substring(2)}`,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
@@ -55,9 +57,9 @@ const createTestFile = (name: string, content: string, type: string): File => {
|
||||
return new File([content], name, { type });
|
||||
};
|
||||
|
||||
const createPDFFile = (): File => {
|
||||
const createPDFFile = (): FileWithId => {
|
||||
const pdfContent = '%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\ntrailer\n<<\n/Size 2\n/Root 1 0 R\n>>\nstartxref\n0\n%%EOF';
|
||||
return createTestFile('test.pdf', pdfContent, 'application/pdf');
|
||||
return createTestFileWithId('test.pdf', pdfContent, 'application/pdf');
|
||||
};
|
||||
|
||||
// Test wrapper component
|
||||
@@ -162,7 +164,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const testFile = createTestFile('invalid.txt', 'not a pdf', 'text/plain');
|
||||
const testFile = createTestFileWithId('invalid.txt', 'not a pdf', 'text/plain');
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
@@ -426,7 +428,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
});
|
||||
const files = [
|
||||
createPDFFile(),
|
||||
createTestFile('test2.pdf', '%PDF-1.4...', 'application/pdf')
|
||||
createTestFileWithId('test2.pdf', '%PDF-1.4...', 'application/pdf')
|
||||
]
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
@@ -527,7 +529,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const corruptedFile = createTestFile('corrupted.pdf', 'not-a-pdf', 'application/pdf');
|
||||
const corruptedFile = createTestFileWithId('corrupted.pdf', 'not-a-pdf', 'application/pdf');
|
||||
const parameters: ConvertParameters = {
|
||||
fromExtension: 'pdf',
|
||||
toExtension: 'png',
|
||||
|
||||
@@ -14,6 +14,8 @@ import i18n from '../../i18n/config';
|
||||
import axios from 'axios';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import { FIT_OPTIONS } from '../../constants/convertConstants';
|
||||
import { createTestFileWithId, createTestFilesWithId } from '../utils/testFileHelpers';
|
||||
import { FileWithId } from '../../types/fileContext';
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
@@ -25,7 +27,7 @@ vi.mock('../../services/fileStorage', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
return Promise.resolve({
|
||||
id: `mock-id-${file.name}`,
|
||||
id: `mock-uuid-${Math.random().toString(36).substring(2)}`,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
@@ -81,7 +83,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock DOCX file
|
||||
const docxFile = new File(['docx content'], 'document.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
|
||||
const docxFile = createTestFileWithId('document.docx', 'docx content', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
@@ -117,7 +119,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock unknown file
|
||||
const unknownFile = new File(['unknown content'], 'document.xyz', { type: 'application/octet-stream' });
|
||||
const unknownFile = createTestFileWithId('document.xyz', 'unknown content', 'application/octet-stream');
|
||||
|
||||
// Test auto-detection
|
||||
act(() => {
|
||||
@@ -156,11 +158,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock image files
|
||||
const imageFiles = [
|
||||
new File(['jpg content'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['png content'], 'photo2.png', { type: 'image/png' }),
|
||||
new File(['gif content'], 'photo3.gif', { type: 'image/gif' })
|
||||
];
|
||||
const imageFiles = createTestFilesWithId([
|
||||
{ name: 'photo1.jpg', content: 'jpg content', type: 'image/jpeg' },
|
||||
{ name: 'photo2.png', content: 'png content', type: 'image/png' },
|
||||
{ name: 'photo3.gif', content: 'gif content', type: 'image/gif' }
|
||||
]);
|
||||
|
||||
// Test smart detection for all images
|
||||
act(() => {
|
||||
@@ -202,11 +204,11 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mixed file types
|
||||
const mixedFiles = [
|
||||
new File(['pdf content'], 'document.pdf', { type: 'application/pdf' }),
|
||||
new File(['docx content'], 'spreadsheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }),
|
||||
new File(['pptx content'], 'presentation.pptx', { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
|
||||
];
|
||||
const mixedFiles = createTestFilesWithId([
|
||||
{ name: 'document.pdf', content: 'pdf content', type: 'application/pdf' },
|
||||
{ name: 'spreadsheet.xlsx', content: 'docx content', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||
{ name: 'presentation.pptx', content: 'pptx content', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }
|
||||
]);
|
||||
|
||||
// Test smart detection for mixed types
|
||||
act(() => {
|
||||
@@ -243,10 +245,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
});
|
||||
|
||||
// Create mock web files
|
||||
const webFiles = [
|
||||
new File(['<html>content</html>'], 'page1.html', { type: 'text/html' }),
|
||||
new File(['zip content'], 'site.zip', { type: 'application/zip' })
|
||||
];
|
||||
const webFiles = createTestFilesWithId([
|
||||
{ name: 'page1.html', content: '<html>content</html>', type: 'text/html' },
|
||||
{ name: 'site.zip', content: 'zip content', type: 'application/zip' }
|
||||
]);
|
||||
|
||||
// Test smart detection for web files
|
||||
act(() => {
|
||||
@@ -288,7 +290,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const htmlFile = new File(['<html>content</html>'], 'page.html', { type: 'text/html' });
|
||||
const htmlFile = createTestFileWithId('page.html', '<html>content</html>', 'text/html');
|
||||
|
||||
// Set up HTML conversion parameters
|
||||
act(() => {
|
||||
@@ -318,7 +320,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const emlFile = new File(['email content'], 'email.eml', { type: 'message/rfc822' });
|
||||
const emlFile = createTestFileWithId('email.eml', 'email content', 'message/rfc822');
|
||||
|
||||
// Set up email conversion parameters
|
||||
act(() => {
|
||||
@@ -355,7 +357,7 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const pdfFile = new File(['pdf content'], 'document.pdf', { type: 'application/pdf' });
|
||||
const pdfFile = createTestFileWithId('document.pdf', 'pdf content', 'application/pdf');
|
||||
|
||||
// Set up PDF/A conversion parameters
|
||||
act(() => {
|
||||
@@ -392,10 +394,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
const imageFiles = createTestFilesWithId([
|
||||
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||
]);
|
||||
|
||||
// Set up image conversion parameters
|
||||
act(() => {
|
||||
@@ -432,10 +434,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
wrapper: TestWrapper
|
||||
});
|
||||
|
||||
const imageFiles = [
|
||||
new File(['jpg1'], 'photo1.jpg', { type: 'image/jpeg' }),
|
||||
new File(['jpg2'], 'photo2.jpg', { type: 'image/jpeg' })
|
||||
];
|
||||
const imageFiles = createTestFilesWithId([
|
||||
{ name: 'photo1.jpg', content: 'jpg1', type: 'image/jpeg' },
|
||||
{ name: 'photo2.jpg', content: 'jpg2', type: 'image/jpeg' }
|
||||
]);
|
||||
|
||||
// Set up for separate processing
|
||||
act(() => {
|
||||
@@ -477,10 +479,10 @@ describe('Convert Tool - Smart Detection Integration Tests', () => {
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('File 2 failed'));
|
||||
|
||||
const mixedFiles = [
|
||||
new File(['file1'], 'doc1.txt', { type: 'text/plain' }),
|
||||
new File(['file2'], 'doc2.xyz', { type: 'application/octet-stream' })
|
||||
];
|
||||
const mixedFiles = createTestFilesWithId([
|
||||
{ name: 'doc1.txt', content: 'file1', type: 'text/plain' },
|
||||
{ name: 'doc2.xyz', content: 'file2', type: 'application/octet-stream' }
|
||||
]);
|
||||
|
||||
// Set up for separate processing (mixed smart detection)
|
||||
act(() => {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const LOCALES_DIR = path.join(__dirname, '../../public/locales');
|
||||
|
||||
// Get all locale directories for parameterized tests
|
||||
const getLocaleDirectories = () => {
|
||||
if (!fs.existsSync(LOCALES_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(LOCALES_DIR, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
};
|
||||
|
||||
const localeDirectories = getLocaleDirectories();
|
||||
|
||||
describe('Translation JSON Validation', () => {
|
||||
test('should find the locales directory', () => {
|
||||
expect(fs.existsSync(LOCALES_DIR)).toBe(true);
|
||||
});
|
||||
|
||||
test('should have at least one locale directory', () => {
|
||||
expect(localeDirectories.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test.each(localeDirectories)('should have valid JSON in %s/translation.json', (localeDir) => {
|
||||
const translationFile = path.join(LOCALES_DIR, localeDir, 'translation.json');
|
||||
|
||||
// Check if file exists
|
||||
expect(fs.existsSync(translationFile)).toBe(true);
|
||||
|
||||
// Read file content
|
||||
const content = fs.readFileSync(translationFile, 'utf8');
|
||||
expect(content.trim()).not.toBe('');
|
||||
|
||||
// Parse JSON - this will throw if invalid JSON
|
||||
let jsonData;
|
||||
expect(() => {
|
||||
jsonData = JSON.parse(content);
|
||||
}).not.toThrow();
|
||||
|
||||
// Ensure it's an object at root level
|
||||
expect(typeof jsonData).toBe('object');
|
||||
expect(jsonData).not.toBeNull();
|
||||
expect(Array.isArray(jsonData)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Test utilities for creating FileWithId objects in tests
|
||||
*/
|
||||
|
||||
import { FileWithId, createFileWithId } from '../../types/fileContext';
|
||||
|
||||
/**
|
||||
* Create a FileWithId object for testing purposes
|
||||
*/
|
||||
export function createTestFileWithId(
|
||||
name: string,
|
||||
content: string = 'test content',
|
||||
type: string = 'application/pdf'
|
||||
): FileWithId {
|
||||
const file = new File([content], name, { type });
|
||||
return createFileWithId(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple FileWithId objects for testing
|
||||
*/
|
||||
export function createTestFilesWithId(
|
||||
files: Array<{ name: string; content?: string; type?: string }>
|
||||
): FileWithId[] {
|
||||
return files.map(({ name, content = 'test content', type = 'application/pdf' }) =>
|
||||
createTestFileWithId(name, content, type)
|
||||
);
|
||||
}
|
||||
@@ -318,7 +318,7 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
control: {
|
||||
color: 'var(--text-secondary)',
|
||||
'[dataActive]': {
|
||||
'[data-active]': {
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
color: 'var(--text-primary)',
|
||||
boxShadow: 'var(--shadow-sm)',
|
||||
|
||||
@@ -60,6 +60,11 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
await addPasswordOperation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = addPasswordOperation.files.length > 0 || addPasswordOperation.downloadUrl !== null;
|
||||
const passwordsCollapsed = !hasFiles || hasResults;
|
||||
@@ -110,6 +115,7 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
operation: addPasswordOperation,
|
||||
title: t("addPassword.results.title", "Encrypted PDFs"),
|
||||
onFileClick: handleThumbnailClick,
|
||||
onUndo: handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user