mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Restructure/frontend editor (#6404)
## Move editor under `frontend/editor/`
Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.
### Why
`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
lint config / Storybook.
### What moves
frontend/
├── editor/ ← NEW: everything editor-specific
│ ├── src/ ← was frontend/src/
│ ├── public/ ← was frontend/public/
│ ├── src-tauri/ ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
│ ├── tsconfig*.json, tailwind.config.js, postcss.config.js
│ ├── scripts/
│ ├── .env, .env.desktop, .env.saas
│ └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
├── .gitignore
└── README.md
### Wiring edits (40 files)
- `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
- `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
- `docker/frontend/Dockerfile`
- 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
`.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
- `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
`frontend/editor/DeveloperGuide.md`
Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
relative to script).
### Verification
| Check | Result |
|---|---|
| `task frontend:typecheck:all` (6 variants) | exit 0 |
| `task frontend:lint` (eslint + dpdm) | exit 0 |
| `task frontend:format:check` | exit 0 |
| `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
| `playwright test --list --project=stubbed` | 172 tests discovered |
`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
wrong it wouldn't have built.
### Test plan
- [ ] `frontend-validation.yml` green
- [ ] `e2e-stubbed.yml` green
- [ ] `tauri-build.yml` green on at least one platform
- [ ] `check_toml.yml` runs on a translation-touching PR
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -0,0 +1,124 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
|
||||
import { useAddAttachmentsOperation } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import AddAttachmentsSettings from "@app/components/tools/addAttachments/AddAttachmentsSettings";
|
||||
import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachmentsTips";
|
||||
|
||||
const AddAttachments = ({
|
||||
onPreviewFile,
|
||||
onComplete,
|
||||
onError,
|
||||
}: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
const addAttachmentsTips = useAddAttachmentsTips();
|
||||
|
||||
const params = useAddAttachmentsParameters();
|
||||
const operation = useAddAttachmentsOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled("add-attachments");
|
||||
|
||||
useEffect(() => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [params.parameters]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(
|
||||
error?.message ||
|
||||
t(
|
||||
"AddAttachmentsRequest.error.failed",
|
||||
"Add attachments operation failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults =
|
||||
operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
|
||||
enum AddAttachmentsStep {
|
||||
NONE = "none",
|
||||
ATTACHMENTS = "attachments",
|
||||
}
|
||||
|
||||
const accordion = useAccordionSteps<AddAttachmentsStep>({
|
||||
noneValue: AddAttachmentsStep.NONE,
|
||||
initialStep: AddAttachmentsStep.ATTACHMENTS,
|
||||
stateConditions: {
|
||||
hasFiles,
|
||||
hasResults: false, // Don't collapse when there are results for add attachments
|
||||
},
|
||||
afterResults: () => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
});
|
||||
|
||||
const getSteps = () => {
|
||||
const steps: any[] = [];
|
||||
|
||||
// Step 1: Attachments Selection
|
||||
steps.push({
|
||||
title: t("AddAttachmentsRequest.attachments", "Select Attachments"),
|
||||
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
|
||||
isVisible: true,
|
||||
tooltip: addAttachmentsTips,
|
||||
content: (
|
||||
<AddAttachmentsSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: getSteps(),
|
||||
executeButton: {
|
||||
text: t("AddAttachmentsRequest.submit", "Add Attachments"),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: handleExecute,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: operation,
|
||||
title: t("AddAttachmentsRequest.results.title", "Attachment Results"),
|
||||
onFileClick: (file) => onPreviewFile?.(file),
|
||||
onUndo: async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
AddAttachments.tool = () => useAddAttachmentsOperation;
|
||||
|
||||
export default AddAttachments as ToolComponent;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createStampTool } from "@app/tools/stamp/createStampTool";
|
||||
|
||||
// AddImage allows users to place image-only stamps
|
||||
const AddImage = createStampTool({
|
||||
toolId: "addImage",
|
||||
translationScope: "addImage",
|
||||
allowedSignatureSources: ["image"],
|
||||
defaultSignatureSource: "image",
|
||||
defaultSignatureType: "image",
|
||||
enableApplyAction: true,
|
||||
});
|
||||
|
||||
export default AddImage;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useAddPageNumbersParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||
import { useAddPageNumbersOperation } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import AddPageNumbersPositionSettings from "@app/components/tools/addPageNumbers/AddPageNumbersPositionSettings";
|
||||
import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
|
||||
|
||||
const AddPageNumbers = ({
|
||||
onPreviewFile,
|
||||
onComplete,
|
||||
onError,
|
||||
}: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const params = useAddPageNumbersParameters();
|
||||
const operation = useAddPageNumbersOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled("add-page-numbers");
|
||||
|
||||
useEffect(() => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [params.parameters]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(
|
||||
error?.message ||
|
||||
t("addPageNumbers.error.failed", "Add page numbers operation failed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults =
|
||||
operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
|
||||
enum AddPageNumbersStep {
|
||||
NONE = "none",
|
||||
POSITION_AND_PAGES = "position_and_pages",
|
||||
CUSTOMIZE = "customize",
|
||||
}
|
||||
|
||||
const accordion = useAccordionSteps<AddPageNumbersStep>({
|
||||
noneValue: AddPageNumbersStep.NONE,
|
||||
initialStep: AddPageNumbersStep.POSITION_AND_PAGES,
|
||||
stateConditions: {
|
||||
hasFiles,
|
||||
hasResults,
|
||||
},
|
||||
afterResults: () => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
});
|
||||
|
||||
const getSteps = () => {
|
||||
const steps: any[] = [];
|
||||
|
||||
// Step 1: Position Selection & Pages/Starting Number
|
||||
steps.push({
|
||||
title: t("addPageNumbers.positionAndPages", "Position & Pages"),
|
||||
isCollapsed: accordion.getCollapsedState(
|
||||
AddPageNumbersStep.POSITION_AND_PAGES,
|
||||
),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<AddPageNumbersPositionSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
file={selectedFiles[0] || null}
|
||||
showQuickGrid={true}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Step 2: Customize Appearance
|
||||
steps.push({
|
||||
title: t("addPageNumbers.customize", "Customize Appearance"),
|
||||
isCollapsed: accordion.getCollapsedState(AddPageNumbersStep.CUSTOMIZE),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(AddPageNumbersStep.CUSTOMIZE),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<AddPageNumbersAppearanceSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: getSteps(),
|
||||
executeButton: {
|
||||
text: t("addPageNumbers.submit", "Add Page Numbers"),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: handleExecute,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: operation,
|
||||
title: t("addPageNumbers.results.title", "Page Number Results"),
|
||||
onFileClick: (file) => onPreviewFile?.(file),
|
||||
onUndo: async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
AddPageNumbers.tool = () => useAddPageNumbersOperation;
|
||||
|
||||
export default AddPageNumbers as ToolComponent;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
|
||||
import ChangePermissionsSettings from "@app/components/tools/changePermissions/ChangePermissionsSettings";
|
||||
|
||||
import { useAddPasswordParameters } from "@app/hooks/tools/addPassword/useAddPasswordParameters";
|
||||
import { useAddPasswordOperation } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
|
||||
import { useAddPasswordTips } from "@app/components/tooltips/useAddPasswordTips";
|
||||
import { useAddPasswordPermissionsTips } from "@app/components/tooltips/useAddPasswordPermissionsTips";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
||||
|
||||
const addPasswordParams = useAddPasswordParameters();
|
||||
const addPasswordOperation = useAddPasswordOperation();
|
||||
const addPasswordTips = useAddPasswordTips();
|
||||
const addPasswordPermissionsTips = useAddPasswordPermissionsTips();
|
||||
|
||||
// Endpoint validation
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled(addPasswordParams.getEndpointName());
|
||||
|
||||
useEffect(() => {
|
||||
addPasswordOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [addPasswordParams.parameters]);
|
||||
|
||||
const handleAddPassword = async () => {
|
||||
try {
|
||||
await addPasswordOperation.executeOperation(
|
||||
addPasswordParams.fullParameters,
|
||||
selectedFiles,
|
||||
);
|
||||
if (addPasswordOperation.files && onComplete) {
|
||||
onComplete(addPasswordOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("addPassword.error.failed", "Add password operation failed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem("previousMode", "addPassword");
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
addPasswordOperation.resetResults();
|
||||
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;
|
||||
const permissionsCollapsed = collapsedPermissions || hasResults;
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("addPassword.passwords.stepTitle", "Passwords & Encryption"),
|
||||
isCollapsed: passwordsCollapsed,
|
||||
onCollapsedClick: hasResults ? handleSettingsReset : undefined,
|
||||
tooltip: addPasswordTips,
|
||||
content: (
|
||||
<AddPasswordSettings
|
||||
parameters={addPasswordParams.parameters}
|
||||
onParameterChange={addPasswordParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("changePermissions.title", "Document Permissions"),
|
||||
isCollapsed: permissionsCollapsed,
|
||||
onCollapsedClick: hasResults
|
||||
? handleSettingsReset
|
||||
: () => setCollapsedPermissions(!collapsedPermissions),
|
||||
content: (
|
||||
<ChangePermissionsSettings
|
||||
parameters={addPasswordParams.permissions.parameters}
|
||||
onParameterChange={addPasswordParams.permissions.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
tooltip: addPasswordPermissionsTips,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("addPassword.submit", "Encrypt"),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: handleAddPassword,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: addPasswordParams.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: addPasswordOperation,
|
||||
title: t("addPassword.results.title", "Encrypted PDFs"),
|
||||
onFileClick: handleThumbnailClick,
|
||||
onUndo: handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default AddPassword as ToolComponent;
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useAddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters";
|
||||
import { useAddStampOperation } from "@app/components/tools/addStamp/useAddStampOperation";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import StampPreview from "@app/components/tools/addStamp/StampPreview";
|
||||
import styles from "@app/components/tools/addStamp/StampPreview.module.css";
|
||||
import ButtonSelector from "@app/components/shared/ButtonSelector";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import ObscuredOverlay from "@app/components/shared/ObscuredOverlay";
|
||||
import StampSetupSettings from "@app/components/tools/addStamp/StampSetupSettings";
|
||||
import StampPositionFormattingSettings from "@app/components/tools/addStamp/StampPositionFormattingSettings";
|
||||
|
||||
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const [quickPositionModeSelected, setQuickPositionModeSelected] =
|
||||
useState(false);
|
||||
const [customPositionModeSelected, setCustomPositionModeSelected] =
|
||||
useState(true);
|
||||
|
||||
const params = useAddStampParameters();
|
||||
const operation = useAddStampOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled("add-stamp");
|
||||
|
||||
useEffect(() => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [params.parameters]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(
|
||||
error?.message ||
|
||||
t("AddStampRequest.error.failed", "Add stamp operation failed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults =
|
||||
operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
|
||||
enum AddStampStep {
|
||||
NONE = "none",
|
||||
STAMP_SETUP = "stampSetup",
|
||||
POSITION_FORMATTING = "positionFormatting",
|
||||
}
|
||||
|
||||
const accordion = useAccordionSteps<AddStampStep>({
|
||||
noneValue: AddStampStep.NONE,
|
||||
initialStep: AddStampStep.STAMP_SETUP,
|
||||
stateConditions: {
|
||||
hasFiles,
|
||||
hasResults,
|
||||
},
|
||||
afterResults: () => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
});
|
||||
|
||||
const getSteps = () => {
|
||||
const steps: any[] = [];
|
||||
|
||||
// Step 1: Stamp Setup
|
||||
steps.push({
|
||||
title: t("AddStampRequest.stampSetup", "Stamp Setup"),
|
||||
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<StampSetupSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
filename={selectedFiles[0]?.name}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Step 2: Formatting & Position
|
||||
steps.push({
|
||||
title: t(
|
||||
"AddStampRequest.positionAndFormatting",
|
||||
"Position & Formatting",
|
||||
),
|
||||
isCollapsed: accordion.getCollapsedState(
|
||||
AddStampStep.POSITION_FORMATTING,
|
||||
),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(AddStampStep.POSITION_FORMATTING),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<Stack gap="md" justify="space-between">
|
||||
{/* Mode toggle: Quick grid vs Custom drag - only show for image stamps */}
|
||||
{params.parameters.stampType === "image" && (
|
||||
<ButtonSelector
|
||||
value={quickPositionModeSelected ? "quick" : "custom"}
|
||||
onChange={(v: "quick" | "custom") => {
|
||||
const isQuick = v === "quick";
|
||||
setQuickPositionModeSelected(isQuick);
|
||||
setCustomPositionModeSelected(!isQuick);
|
||||
}}
|
||||
options={[
|
||||
{ value: "quick", label: t("quickPosition", "Quick Position") },
|
||||
{
|
||||
value: "custom",
|
||||
label: t("customPosition", "Custom Position"),
|
||||
},
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
buttonClassName={styles.modeToggleButton}
|
||||
textClassName={styles.modeToggleButtonText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{params.parameters.stampType === "image" &&
|
||||
customPositionModeSelected && (
|
||||
<div className={styles.informationContainer}>
|
||||
<Text className={styles.informationText}>
|
||||
{t(
|
||||
"AddStampRequest.customPosition",
|
||||
"Drag the stamp to the desired location in the preview window.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{params.parameters.stampType === "image" &&
|
||||
!customPositionModeSelected && (
|
||||
<div className={styles.informationContainer}>
|
||||
<Text className={styles.informationText}>
|
||||
{t(
|
||||
"AddStampRequest.quickPosition",
|
||||
"Select a position on the page to place the stamp.",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StampPositionFormattingSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
|
||||
{/* Unified preview wrapped with obscured overlay if no stamp selected */}
|
||||
<ObscuredOverlay
|
||||
obscured={
|
||||
accordion.currentStep === AddStampStep.POSITION_FORMATTING &&
|
||||
((params.parameters.stampType === "text" &&
|
||||
params.parameters.stampText.trim().length === 0) ||
|
||||
(params.parameters.stampType === "image" &&
|
||||
!params.parameters.stampImage))
|
||||
}
|
||||
overlayMessage={
|
||||
<Text size="sm" c="white" fw={600}>
|
||||
{t(
|
||||
"AddStampRequest.noStampSelected",
|
||||
"No stamp selected. Return to Step 1.",
|
||||
)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<StampPreview
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
file={selectedFiles[0] || null}
|
||||
showQuickGrid={
|
||||
params.parameters.stampType === "text"
|
||||
? true
|
||||
: quickPositionModeSelected
|
||||
}
|
||||
/>
|
||||
</ObscuredOverlay>
|
||||
</Stack>
|
||||
),
|
||||
});
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: getSteps(),
|
||||
executeButton: {
|
||||
text: t("AddStampRequest.submit", "Add Stamp"),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: handleExecute,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: operation,
|
||||
title: t("AddStampRequest.results.title", "Stamp Results"),
|
||||
onFileClick: (file) => onPreviewFile?.(file),
|
||||
onUndo: async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
AddStamp.tool = () => useAddStampOperation;
|
||||
|
||||
export default AddStamp as ToolComponent;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createStampTool } from "@app/tools/stamp/createStampTool";
|
||||
|
||||
// AddText is text-only annotation (no drawing, no images, no save-to-library)
|
||||
const AddText = createStampTool({
|
||||
toolId: "addText",
|
||||
translationScope: "addText",
|
||||
allowedSignatureSources: ["text"],
|
||||
defaultSignatureSource: "text",
|
||||
defaultSignatureType: "text",
|
||||
enableApplyAction: true,
|
||||
});
|
||||
|
||||
export default AddText;
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
import WatermarkTypeSettings from "@app/components/tools/addWatermark/WatermarkTypeSettings";
|
||||
import WatermarkWording from "@app/components/tools/addWatermark/WatermarkWording";
|
||||
import WatermarkTextStyle from "@app/components/tools/addWatermark/WatermarkTextStyle";
|
||||
import WatermarkImageFile from "@app/components/tools/addWatermark/WatermarkImageFile";
|
||||
import WatermarkFormatting from "@app/components/tools/addWatermark/WatermarkFormatting";
|
||||
|
||||
import { useAddWatermarkParameters } from "@app/hooks/tools/addWatermark/useAddWatermarkParameters";
|
||||
import { useAddWatermarkOperation } from "@app/hooks/tools/addWatermark/useAddWatermarkOperation";
|
||||
import {
|
||||
useWatermarkTypeTips,
|
||||
useWatermarkWordingTips,
|
||||
useWatermarkTextStyleTips,
|
||||
useWatermarkFileTips,
|
||||
useWatermarkFormattingTips,
|
||||
} from "@app/components/tooltips/useWatermarkTips";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const AddWatermark = ({
|
||||
onPreviewFile,
|
||||
onComplete,
|
||||
onError,
|
||||
}: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const [collapsedType, setCollapsedType] = useState(false);
|
||||
const [collapsedStyle, setCollapsedStyle] = useState(true);
|
||||
const [collapsedFormatting, setCollapsedFormatting] = useState(true);
|
||||
|
||||
const watermarkParams = useAddWatermarkParameters();
|
||||
const watermarkOperation = useAddWatermarkOperation();
|
||||
const watermarkTypeTips = useWatermarkTypeTips();
|
||||
const watermarkWordingTips = useWatermarkWordingTips();
|
||||
const watermarkTextStyleTips = useWatermarkTextStyleTips();
|
||||
const watermarkFileTips = useWatermarkFileTips();
|
||||
const watermarkFormattingTips = useWatermarkFormattingTips();
|
||||
|
||||
// Endpoint validation
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled("add-watermark");
|
||||
|
||||
useEffect(() => {
|
||||
watermarkOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [watermarkParams.parameters]);
|
||||
|
||||
// Auto-collapse type step after selection
|
||||
useEffect(() => {
|
||||
if (watermarkParams.parameters.watermarkType && !collapsedType) {
|
||||
setCollapsedType(true);
|
||||
}
|
||||
}, [watermarkParams.parameters.watermarkType]);
|
||||
|
||||
const handleAddWatermark = async () => {
|
||||
try {
|
||||
await watermarkOperation.executeOperation(
|
||||
watermarkParams.parameters,
|
||||
selectedFiles,
|
||||
);
|
||||
if (watermarkOperation.files && onComplete) {
|
||||
onComplete(watermarkOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("watermark.error.failed", "Add watermark operation failed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem("previousMode", "watermark");
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
watermarkOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
await watermarkOperation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults =
|
||||
watermarkOperation.files.length > 0 ||
|
||||
watermarkOperation.downloadUrl !== null;
|
||||
|
||||
// Dynamic step structure based on watermark type
|
||||
const getSteps = () => {
|
||||
const steps = [];
|
||||
|
||||
steps.push({
|
||||
title: t("watermark.steps.type", "Watermark Type"),
|
||||
isCollapsed: hasResults ? true : collapsedType,
|
||||
isVisible: hasFiles || hasResults,
|
||||
onCollapsedClick: hasResults
|
||||
? handleSettingsReset
|
||||
: () => setCollapsedType(!collapsedType),
|
||||
tooltip: watermarkTypeTips,
|
||||
content: (
|
||||
<WatermarkTypeSettings
|
||||
watermarkType={watermarkParams.parameters.watermarkType}
|
||||
onWatermarkTypeChange={(type) =>
|
||||
watermarkParams.updateParameter("watermarkType", type)
|
||||
}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
if (hasFiles || hasResults) {
|
||||
// Text watermark path
|
||||
if (watermarkParams.parameters.watermarkType === "text") {
|
||||
// Step 2: Wording
|
||||
steps.push({
|
||||
title: t("watermark.steps.wording", "Wording"),
|
||||
isCollapsed: hasResults,
|
||||
tooltip: watermarkWordingTips,
|
||||
content: (
|
||||
<WatermarkWording
|
||||
parameters={watermarkParams.parameters}
|
||||
onParameterChange={watermarkParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Step 3: Style
|
||||
steps.push({
|
||||
title: t("watermark.steps.textStyle", "Style"),
|
||||
isCollapsed: hasResults ? true : collapsedStyle,
|
||||
onCollapsedClick: hasResults
|
||||
? handleSettingsReset
|
||||
: () => setCollapsedStyle(!collapsedStyle),
|
||||
tooltip: watermarkTextStyleTips,
|
||||
content: (
|
||||
<WatermarkTextStyle
|
||||
parameters={watermarkParams.parameters}
|
||||
onParameterChange={watermarkParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Step 4: Formatting
|
||||
steps.push({
|
||||
title: t("watermark.steps.formatting", "Formatting"),
|
||||
isCollapsed: hasResults ? true : collapsedFormatting,
|
||||
onCollapsedClick: hasResults
|
||||
? handleSettingsReset
|
||||
: () => setCollapsedFormatting(!collapsedFormatting),
|
||||
tooltip: watermarkFormattingTips,
|
||||
content: (
|
||||
<WatermarkFormatting
|
||||
parameters={watermarkParams.parameters}
|
||||
onParameterChange={watermarkParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Image watermark path
|
||||
if (watermarkParams.parameters.watermarkType === "image") {
|
||||
// Step 2: Watermark File
|
||||
steps.push({
|
||||
title: t("watermark.steps.file", "Watermark File"),
|
||||
isCollapsed: hasResults,
|
||||
tooltip: watermarkFileTips,
|
||||
content: (
|
||||
<WatermarkImageFile
|
||||
parameters={watermarkParams.parameters}
|
||||
onParameterChange={watermarkParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Step 3: Formatting
|
||||
steps.push({
|
||||
title: t("watermark.steps.formatting", "Formatting"),
|
||||
isCollapsed: hasResults ? true : collapsedFormatting,
|
||||
onCollapsedClick: hasResults
|
||||
? handleSettingsReset
|
||||
: () => setCollapsedFormatting(!collapsedFormatting),
|
||||
tooltip: watermarkFormattingTips,
|
||||
content: (
|
||||
<WatermarkFormatting
|
||||
parameters={watermarkParams.parameters}
|
||||
onParameterChange={watermarkParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: getSteps(),
|
||||
executeButton: {
|
||||
text: t("watermark.submit", "Add Watermark"),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: handleAddWatermark,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: watermarkParams.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: watermarkOperation,
|
||||
title: t("watermark.results.title", "Watermark Results"),
|
||||
onFileClick: handleThumbnailClick,
|
||||
onUndo: handleUndo,
|
||||
},
|
||||
forceStepNumbers: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
AddWatermark.tool = () => useAddWatermarkOperation;
|
||||
|
||||
export default AddWatermark as ToolComponent;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useAdjustContrastParameters } from "@app/hooks/tools/adjustContrast/useAdjustContrastParameters";
|
||||
import { useAdjustContrastOperation } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation";
|
||||
import AdjustContrastBasicSettings from "@app/components/tools/adjustContrast/AdjustContrastBasicSettings";
|
||||
import AdjustContrastColorSettings from "@app/components/tools/adjustContrast/AdjustContrastColorSettings";
|
||||
import AdjustContrastPreview from "@app/components/tools/adjustContrast/AdjustContrastPreview";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import NavigationArrows from "@app/components/shared/filePreview/NavigationArrows";
|
||||
|
||||
const AdjustContrast = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"adjustContrast",
|
||||
useAdjustContrastParameters,
|
||||
useAdjustContrastOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
enum Step {
|
||||
NONE = "none",
|
||||
BASIC = "basic",
|
||||
COLORS = "colors",
|
||||
}
|
||||
const accordion = useAccordionSteps<Step>({
|
||||
noneValue: Step.NONE,
|
||||
initialStep: Step.BASIC,
|
||||
stateConditions: { hasFiles: base.hasFiles, hasResults: base.hasResults },
|
||||
afterResults: base.handleSettingsReset,
|
||||
});
|
||||
|
||||
// Track which selected file is being previewed. Clamp when selection changes.
|
||||
const [previewIndex, setPreviewIndex] = useState(0);
|
||||
const totalSelected = base.selectedFiles.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (previewIndex >= totalSelected) {
|
||||
setPreviewIndex(Math.max(0, totalSelected - 1));
|
||||
}
|
||||
}, [totalSelected, previewIndex]);
|
||||
|
||||
const currentFile = useMemo(() => {
|
||||
return totalSelected > 0 ? base.selectedFiles[previewIndex] : null;
|
||||
}, [base.selectedFiles, previewIndex, totalSelected]);
|
||||
|
||||
const handlePrev = () => setPreviewIndex((i) => Math.max(0, i - 1));
|
||||
const handleNext = () =>
|
||||
setPreviewIndex((i) => Math.min(totalSelected - 1, i + 1));
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("adjustContrast.basic", "Basic Adjustments"),
|
||||
isCollapsed: accordion.getCollapsedState(Step.BASIC),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(Step.BASIC),
|
||||
content: (
|
||||
<AdjustContrastBasicSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("adjustContrast.adjustColors", "Adjust Colors"),
|
||||
isCollapsed: accordion.getCollapsedState(Step.COLORS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(Step.COLORS),
|
||||
content: (
|
||||
<AdjustContrastColorSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
preview: (
|
||||
<div>
|
||||
<NavigationArrows
|
||||
onPrevious={handlePrev}
|
||||
onNext={handleNext}
|
||||
disabled={totalSelected <= 1}
|
||||
>
|
||||
<div style={{ width: "100%" }}>
|
||||
<AdjustContrastPreview
|
||||
file={currentFile || null}
|
||||
parameters={base.params.parameters}
|
||||
/>
|
||||
</div>
|
||||
</NavigationArrows>
|
||||
{totalSelected > 1 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginTop: 8,
|
||||
fontSize: 12,
|
||||
color: "var(--text-color-muted)",
|
||||
}}
|
||||
>
|
||||
{`${previewIndex + 1} of ${totalSelected}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
executeButton: {
|
||||
text: t("adjustContrast.confirm", "Confirm"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
paramsValid: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("adjustContrast.results.title", "Adjusted PDF"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
forceStepNumbers: true,
|
||||
});
|
||||
};
|
||||
|
||||
export default AdjustContrast as ToolComponent;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import AdjustPageScaleSettings from "@app/components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
import { useAdjustPageScaleParameters } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters";
|
||||
import { useAdjustPageScaleOperation } from "@app/hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useAdjustPageScaleTips } from "@app/components/tooltips/useAdjustPageScaleTips";
|
||||
|
||||
const AdjustPageScale = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const adjustPageScaleTips = useAdjustPageScaleTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"adjustPageScale",
|
||||
useAdjustPageScaleParameters,
|
||||
useAdjustPageScaleOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: adjustPageScaleTips,
|
||||
content: (
|
||||
<AdjustPageScaleSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("adjustPageScale.submit", "Adjust Page Scale"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("adjustPageScale.title", "Page Scale Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default AdjustPageScale as ToolComponent;
|
||||
@@ -0,0 +1,758 @@
|
||||
import { useEffect, useState, useContext, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useNavigation } from "@app/contexts/NavigationContext";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { BaseToolProps } from "@app/types/tool";
|
||||
import { useSignature } from "@app/contexts/SignatureContext";
|
||||
import { useAnnotation as useAnnotationContext } from "@app/contexts/AnnotationContext";
|
||||
import { ViewerContext, useViewer } from "@app/contexts/ViewerContext";
|
||||
import type {
|
||||
AnnotationToolId,
|
||||
AnnotationEvent,
|
||||
AnnotationSelection,
|
||||
AnnotationRect,
|
||||
} from "@app/components/viewer/viewerTypes";
|
||||
import { useAnnotationStyleState } from "@app/tools/annotate/useAnnotationStyleState";
|
||||
import { useAnnotationSelection } from "@app/tools/annotate/useAnnotationSelection";
|
||||
import { AnnotationPanel } from "@app/tools/annotate/AnnotationPanel";
|
||||
|
||||
// Tools that require drawing/interacting with the PDF and should disable pan mode
|
||||
const DRAWING_TOOLS: AnnotationToolId[] = [
|
||||
"highlight",
|
||||
"underline",
|
||||
"strikeout",
|
||||
"squiggly",
|
||||
"ink",
|
||||
"inkHighlighter",
|
||||
"text",
|
||||
"note",
|
||||
"textComment",
|
||||
"insertText",
|
||||
"replaceText",
|
||||
"square",
|
||||
"circle",
|
||||
"line",
|
||||
"lineArrow",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"stamp",
|
||||
"signatureStamp",
|
||||
"signatureInk",
|
||||
];
|
||||
|
||||
const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
|
||||
"select",
|
||||
"highlight",
|
||||
"underline",
|
||||
"strikeout",
|
||||
"squiggly",
|
||||
"ink",
|
||||
"inkHighlighter",
|
||||
"text",
|
||||
"note",
|
||||
"textComment",
|
||||
"insertText",
|
||||
"replaceText",
|
||||
"square",
|
||||
"circle",
|
||||
"line",
|
||||
"lineArrow",
|
||||
"polyline",
|
||||
"polygon",
|
||||
"stamp",
|
||||
"signatureStamp",
|
||||
"signatureInk",
|
||||
];
|
||||
|
||||
const isKnownAnnotationTool = (
|
||||
toolId: string | undefined | null,
|
||||
): toolId is AnnotationToolId =>
|
||||
!!toolId && (KNOWN_ANNOTATION_TOOLS as string[]).includes(toolId);
|
||||
|
||||
const Annotate = (_props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedTool, workbench, hasUnsavedChanges } = useNavigation();
|
||||
const { files: allFiles } = useAllFiles();
|
||||
const {
|
||||
signatureApiRef,
|
||||
annotationApiRef,
|
||||
historyApiRef,
|
||||
undo,
|
||||
redo,
|
||||
setSignatureConfig,
|
||||
setPlacementMode,
|
||||
placementPreviewSize,
|
||||
setPlacementPreviewSize,
|
||||
} = useSignature();
|
||||
const { activateAnnotationToolRef } = useAnnotationContext();
|
||||
const viewerContext = useContext(ViewerContext);
|
||||
const viewerContextRef = useRef(viewerContext);
|
||||
useEffect(() => {
|
||||
viewerContextRef.current = viewerContext;
|
||||
}, [viewerContext]);
|
||||
const {
|
||||
getZoomState,
|
||||
registerImmediateZoomUpdate,
|
||||
applyChanges,
|
||||
activeFileIndex,
|
||||
panActions,
|
||||
setCommentsSidebarVisible,
|
||||
} = useViewer();
|
||||
|
||||
const [activeTool, setActiveTool] = useState<AnnotationToolId>("select");
|
||||
|
||||
// Track the previous file index to detect file switches
|
||||
const prevFileIndexRef = useRef<number>(activeFileIndex);
|
||||
const activeToolRef = useRef<AnnotationToolId>("select");
|
||||
const wasAnnotateActiveRef = useRef<boolean>(false);
|
||||
const [selectedTextDraft, setSelectedTextDraft] = useState<string>("");
|
||||
const [selectedFontSize, setSelectedFontSize] = useState<number>(14);
|
||||
const [stampImageData, setStampImageData] = useState<string | undefined>();
|
||||
const [stampImageSize, setStampImageSize] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
} | null>(null);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
const manualToolSwitch = useRef<boolean>(false);
|
||||
|
||||
// Zoom tracking for stamp size conversion
|
||||
const [currentZoom, setCurrentZoom] = useState(() => {
|
||||
const zoomState = getZoomState();
|
||||
if (!zoomState) return 1;
|
||||
if (typeof zoomState.zoomPercent === "number") {
|
||||
return Math.max(zoomState.zoomPercent / 100, 0.01);
|
||||
}
|
||||
return Math.max(zoomState.currentZoom ?? 1, 0.01);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return registerImmediateZoomUpdate((newZoomPercent) => {
|
||||
setCurrentZoom(Math.max(newZoomPercent / 100, 0.01));
|
||||
});
|
||||
}, [registerImmediateZoomUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
activeToolRef.current = activeTool;
|
||||
}, [activeTool]);
|
||||
|
||||
// CSS to PDF size conversion accounting for zoom
|
||||
const cssToPdfSize = useCallback(
|
||||
(size: { width: number; height: number }) => {
|
||||
const zoom = currentZoom || 1;
|
||||
const factor = 1 / zoom;
|
||||
return {
|
||||
width: size.width * factor,
|
||||
height: size.height * factor,
|
||||
};
|
||||
},
|
||||
[currentZoom],
|
||||
);
|
||||
|
||||
const computeStampDisplaySize = useCallback(
|
||||
(natural: { width: number; height: number } | null) => {
|
||||
if (!natural) {
|
||||
return { width: 180, height: 120 };
|
||||
}
|
||||
const maxSide = 260;
|
||||
const minSide = 24;
|
||||
const { width, height } = natural;
|
||||
const largest = Math.max(width || maxSide, height || maxSide, 1);
|
||||
const scale = Math.min(1, maxSide / largest);
|
||||
return {
|
||||
width: Math.max(minSide, Math.round(width * scale)),
|
||||
height: Math.max(minSide, Math.round(height * scale)),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { styleState, styleActions, buildToolOptions, getActiveColor } =
|
||||
useAnnotationStyleState(cssToPdfSize);
|
||||
|
||||
const {
|
||||
setInkWidth,
|
||||
setShapeThickness,
|
||||
setTextColor,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setFreehandHighlighterWidth,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
} = styleActions;
|
||||
|
||||
const handleApplyChanges = useCallback(async () => {
|
||||
if (applyChanges) {
|
||||
await applyChanges();
|
||||
}
|
||||
}, [applyChanges]);
|
||||
|
||||
// Deactivate all annotation tools when the Annotate component unmounts (e.g. switching to Sign tool).
|
||||
// The dep-change effect below only fires while mounted, so unmount needs its own cleanup.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
annotationApiRef?.current?.deactivateTools?.();
|
||||
signatureApiRef?.current?.deactivateTools?.();
|
||||
setPlacementMode(false);
|
||||
viewerContextRef.current?.setAnnotationMode(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isAnnotateActive =
|
||||
workbench === "viewer" && selectedTool === "annotate";
|
||||
if (wasAnnotateActiveRef.current && !isAnnotateActive) {
|
||||
annotationApiRef?.current?.deactivateTools?.();
|
||||
signatureApiRef?.current?.deactivateTools?.();
|
||||
setPlacementMode(false);
|
||||
viewerContext?.setAnnotationMode(false);
|
||||
} else if (!wasAnnotateActiveRef.current && isAnnotateActive) {
|
||||
// When entering annotate mode, activate the select tool by default
|
||||
// Also reset React state to match — EmbedPDF always starts at 'select' here
|
||||
setActiveTool("select");
|
||||
activeToolRef.current = "select";
|
||||
const toolOptions = buildToolOptions("select");
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(
|
||||
"select",
|
||||
toolOptions,
|
||||
);
|
||||
}
|
||||
wasAnnotateActiveRef.current = isAnnotateActive;
|
||||
}, [
|
||||
workbench,
|
||||
selectedTool,
|
||||
annotationApiRef,
|
||||
signatureApiRef,
|
||||
setPlacementMode,
|
||||
buildToolOptions,
|
||||
viewerContext,
|
||||
]);
|
||||
|
||||
// Monitor history state for undo/redo availability
|
||||
useEffect(() => {
|
||||
const historyApi = historyApiRef?.current;
|
||||
if (!historyApi) return;
|
||||
|
||||
const updateAvailability = () =>
|
||||
setHistoryAvailability({
|
||||
canUndo: historyApi.canUndo?.() ?? false,
|
||||
canRedo: historyApi.canRedo?.() ?? false,
|
||||
});
|
||||
|
||||
updateAvailability();
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
if (!historyApi.subscribe) {
|
||||
// Fallback polling in case the history API doesn't support subscriptions
|
||||
interval = setInterval(updateAvailability, 350);
|
||||
} else {
|
||||
const unsubscribe = historyApi.subscribe(updateAvailability);
|
||||
return () => {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [historyApiRef?.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewerContext) return;
|
||||
if (viewerContext.isAnnotationMode) return;
|
||||
|
||||
viewerContext.setAnnotationMode(true);
|
||||
const toolOptions =
|
||||
activeTool === "stamp"
|
||||
? buildToolOptions("stamp", { stampImageData, stampImageSize })
|
||||
: buildToolOptions(activeTool);
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(
|
||||
activeTool,
|
||||
toolOptions,
|
||||
);
|
||||
}, [
|
||||
viewerContext?.isAnnotationMode,
|
||||
signatureApiRef,
|
||||
activeTool,
|
||||
buildToolOptions,
|
||||
stampImageData,
|
||||
stampImageSize,
|
||||
]);
|
||||
|
||||
// Reset to 'select' mode when switching between files
|
||||
// The new PDF gets a fresh EmbedPDF instance - forcing user to re-select tool ensures it works properly
|
||||
useEffect(() => {
|
||||
if (prevFileIndexRef.current !== activeFileIndex) {
|
||||
prevFileIndexRef.current = activeFileIndex;
|
||||
|
||||
// Reset to select mode when switching files
|
||||
// This forces the user to re-select their tool, which ensures proper activation on the new PDF
|
||||
if (activeTool !== "select") {
|
||||
setActiveTool("select");
|
||||
activeToolRef.current = "select";
|
||||
|
||||
// Clean up placement mode if we were in stamp tool
|
||||
setPlacementMode(false);
|
||||
setSignatureConfig(null);
|
||||
|
||||
// Small delay to ensure the new EmbedPDF instance is ready, then activate select mode
|
||||
const timer = setTimeout(() => {
|
||||
if (annotationApiRef?.current) {
|
||||
annotationApiRef.current.deselectAnnotation?.();
|
||||
annotationApiRef.current.activateAnnotationTool?.("select");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [activeFileIndex, activeTool, setPlacementMode, setSignatureConfig]);
|
||||
|
||||
const activateAnnotationTool = (toolId: AnnotationToolId) => {
|
||||
// If leaving stamp tool, clean up placement mode
|
||||
if (activeTool === "stamp" && toolId !== "stamp") {
|
||||
setPlacementMode(false);
|
||||
setSignatureConfig(null);
|
||||
}
|
||||
|
||||
viewerContext?.setAnnotationMode(true);
|
||||
|
||||
// Mark as manual tool switch to prevent auto-switch back
|
||||
manualToolSwitch.current = true;
|
||||
|
||||
// Deselect annotation in the viewer first
|
||||
annotationApiRef?.current?.deselectAnnotation?.();
|
||||
|
||||
// Clear selection state to show default controls
|
||||
setSelectedAnn(null);
|
||||
setSelectedAnnId(null);
|
||||
|
||||
// Disable pan mode when activating drawing tools to avoid conflict
|
||||
if (DRAWING_TOOLS.includes(toolId)) {
|
||||
panActions.disablePan();
|
||||
}
|
||||
|
||||
// Change the tool
|
||||
setActiveTool(toolId);
|
||||
const options =
|
||||
toolId === "stamp"
|
||||
? buildToolOptions("stamp", { stampImageData, stampImageSize })
|
||||
: buildToolOptions(toolId);
|
||||
|
||||
// For stamp, apply the image if we have one
|
||||
annotationApiRef?.current?.setAnnotationStyle?.(toolId, options);
|
||||
annotationApiRef?.current?.activateAnnotationTool?.(
|
||||
toolId === "stamp" ? "stamp" : toolId,
|
||||
options,
|
||||
);
|
||||
|
||||
// Auto-open comments sidebar when a comment tool is selected
|
||||
if (["textComment", "insertText", "replaceText"].includes(toolId)) {
|
||||
setCommentsSidebarVisible(true);
|
||||
}
|
||||
|
||||
// Reset flag after a short delay
|
||||
setTimeout(() => {
|
||||
manualToolSwitch.current = false;
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Keep ref in sync so external callers (e.g. CommentsSidebar) get the latest closure
|
||||
activateAnnotationToolRef.current = activateAnnotationTool;
|
||||
|
||||
useEffect(() => {
|
||||
// push style updates to EmbedPDF when sliders/colors change
|
||||
if (activeTool === "stamp") {
|
||||
const options = buildToolOptions("stamp", {
|
||||
stampImageData,
|
||||
stampImageSize,
|
||||
});
|
||||
annotationApiRef?.current?.setAnnotationStyle?.("stamp", options);
|
||||
} else {
|
||||
annotationApiRef?.current?.setAnnotationStyle?.(
|
||||
activeTool,
|
||||
buildToolOptions(activeTool),
|
||||
);
|
||||
}
|
||||
}, [
|
||||
activeTool,
|
||||
buildToolOptions,
|
||||
signatureApiRef,
|
||||
stampImageData,
|
||||
stampImageSize,
|
||||
]);
|
||||
|
||||
// Sync preview size from overlay to annotation engine
|
||||
useEffect(() => {
|
||||
// When preview size changes, update stamp annotation sizing
|
||||
// The SignatureAPIBridge will use placementPreviewSize from SignatureContext
|
||||
// and apply the converted size to the stamp tool automatically
|
||||
if (activeTool === "stamp" && stampImageData) {
|
||||
const size = placementPreviewSize ?? stampImageSize;
|
||||
const stampOptions = buildToolOptions("stamp", {
|
||||
stampImageData,
|
||||
stampImageSize: size ?? null,
|
||||
});
|
||||
annotationApiRef?.current?.setAnnotationStyle?.("stamp", stampOptions);
|
||||
}
|
||||
}, [
|
||||
placementPreviewSize,
|
||||
activeTool,
|
||||
stampImageData,
|
||||
signatureApiRef,
|
||||
stampImageSize,
|
||||
cssToPdfSize,
|
||||
buildToolOptions,
|
||||
]);
|
||||
|
||||
// Auto-switch to 'select' after placing a note or text annotation
|
||||
// EmbedPDF fires 'create' + committed:true when placement is finalised
|
||||
useEffect(() => {
|
||||
const unsubscribe = annotationApiRef?.current?.onAnnotationEvent?.(
|
||||
(event: AnnotationEvent) => {
|
||||
if (event.type === "create" && event.committed) {
|
||||
const toolId = activeToolRef.current;
|
||||
if (
|
||||
[
|
||||
"text",
|
||||
"note",
|
||||
"textComment",
|
||||
"insertText",
|
||||
"replaceText",
|
||||
].includes(toolId)
|
||||
) {
|
||||
setActiveTool("select");
|
||||
activeToolRef.current = "select";
|
||||
annotationApiRef?.current?.activateAnnotationTool?.("select");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
if (typeof unsubscribe === "function") unsubscribe();
|
||||
};
|
||||
}, [annotationApiRef?.current]);
|
||||
|
||||
// Clipboard ref for copy/paste — no re-render needed
|
||||
const clipboardRef = useRef<{
|
||||
pageIndex: number;
|
||||
annotation: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
|
||||
// Click-outside to blur FreeText/note inline editing so user can then drag the annotation.
|
||||
// When clicking the selection menu (e.g. Properties), only blur — do not deselect so the menu/popover can respond.
|
||||
useEffect(() => {
|
||||
const handleCapture = (e: MouseEvent) => {
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!active?.isContentEditable) return;
|
||||
|
||||
const pageEl = active.closest("[data-page-index]") as HTMLElement | null;
|
||||
if (!pageEl) return;
|
||||
|
||||
const editingWrapper = active.parentElement;
|
||||
const target = e.target as Node;
|
||||
if (editingWrapper?.contains(target)) return;
|
||||
|
||||
active.blur();
|
||||
|
||||
const onSelectionMenu = (target as HTMLElement).closest?.(
|
||||
"[data-annotation-selection-menu]",
|
||||
);
|
||||
if (onSelectionMenu) return;
|
||||
|
||||
const pageParent = pageEl.parentElement;
|
||||
if (!pageParent) return;
|
||||
const synthetic = new PointerEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
});
|
||||
pageParent.dispatchEvent(synthetic);
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleCapture, true);
|
||||
return () => document.removeEventListener("mousedown", handleCapture, true);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts: Escape (cancel drawing), Backspace/Delete (delete selected), Ctrl+C/V (copy/paste)
|
||||
useEffect(() => {
|
||||
const isInputFocused = () => {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = (el as HTMLElement).tagName;
|
||||
return (
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
(el as HTMLElement).isContentEditable
|
||||
);
|
||||
};
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isInputFocused()) return;
|
||||
|
||||
// Backspace / Delete: delete selected annotation(s)
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
const multiSelected =
|
||||
annotationApiRef?.current?.getSelectedAnnotations?.();
|
||||
if (multiSelected && multiSelected.length > 0) {
|
||||
e.preventDefault();
|
||||
const toDelete = multiSelected
|
||||
.map((s: AnnotationSelection) => {
|
||||
const ann = s.object ?? s;
|
||||
return {
|
||||
pageIndex: ann.pageIndex ?? 0,
|
||||
id: ann.id ?? ann.uid ?? "",
|
||||
};
|
||||
})
|
||||
.filter((a: { id: string }) => a.id);
|
||||
if (toDelete.length > 0) {
|
||||
annotationApiRef?.current?.deleteAnnotations?.(toDelete);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const selected: AnnotationSelection | null =
|
||||
annotationApiRef?.current?.getSelectedAnnotation?.() ?? null;
|
||||
if (!selected) return;
|
||||
e.preventDefault();
|
||||
const pageIndex: number =
|
||||
selected.pageIndex ?? selected.object?.pageIndex ?? 0;
|
||||
const annotationId: string =
|
||||
selected.id ??
|
||||
selected.object?.id ??
|
||||
selected.uid ??
|
||||
selected.object?.uid ??
|
||||
"";
|
||||
if (annotationId != null) {
|
||||
annotationApiRef?.current?.deleteAnnotation?.(
|
||||
pageIndex,
|
||||
annotationId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C: copy selected annotation
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
|
||||
const selected: AnnotationSelection | null =
|
||||
annotationApiRef?.current?.getSelectedAnnotation?.() ?? null;
|
||||
if (!selected) return;
|
||||
e.preventDefault();
|
||||
const ann = selected.object ?? selected;
|
||||
const pageIndex: number = ann.pageIndex ?? 0;
|
||||
clipboardRef.current = {
|
||||
pageIndex,
|
||||
annotation: { ...(ann as Record<string, unknown>) },
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+V: paste copied annotation (offset by ~20 PDF pts)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
|
||||
const clip = clipboardRef.current;
|
||||
if (!clip) return;
|
||||
e.preventDefault();
|
||||
const { pageIndex, annotation } = clip;
|
||||
const OFFSET = 20;
|
||||
const pasted: Record<string, unknown> = { ...annotation };
|
||||
// Assign a new id so EmbedPDF tracks the copy and delete works on it
|
||||
pasted.id =
|
||||
typeof crypto !== "undefined" && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `paste-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
delete pasted.uid;
|
||||
// Remove appearance stream reference — the copy needs its own rendering
|
||||
delete pasted.appearanceModes;
|
||||
// Shift the EmbedPDF Rect: { origin: { x, y }, size: { width, height } }
|
||||
// PDF coords have y=0 at bottom, so down = smaller y; right = larger x
|
||||
if (pasted.rect && typeof pasted.rect === "object") {
|
||||
const r = pasted.rect as AnnotationRect;
|
||||
if (r.origin && typeof r.origin === "object") {
|
||||
pasted.rect = {
|
||||
...r,
|
||||
origin: { x: r.origin.x + OFFSET, y: r.origin.y - OFFSET },
|
||||
};
|
||||
}
|
||||
}
|
||||
annotationApiRef?.current?.createAnnotation?.(pageIndex, pasted);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [buildToolOptions, annotationApiRef]);
|
||||
|
||||
const deriveToolFromAnnotation = useCallback(
|
||||
(
|
||||
annotation: AnnotationSelection | null | undefined,
|
||||
): AnnotationToolId | undefined => {
|
||||
if (!annotation) return undefined;
|
||||
const customToolId =
|
||||
annotation.customData?.toolId ||
|
||||
annotation.customData?.annotationToolId;
|
||||
if (isKnownAnnotationTool(customToolId)) {
|
||||
return customToolId;
|
||||
}
|
||||
|
||||
const type = annotation.type ?? annotation.object?.type;
|
||||
switch (type) {
|
||||
case 3:
|
||||
return "text"; // FREETEXT
|
||||
case 4:
|
||||
return "line"; // LINE
|
||||
case 5:
|
||||
return "square"; // SQUARE
|
||||
case 6:
|
||||
return "circle"; // CIRCLE
|
||||
case 7:
|
||||
return "polygon"; // POLYGON
|
||||
case 8:
|
||||
return "polyline"; // POLYLINE
|
||||
case 9:
|
||||
return "highlight"; // HIGHLIGHT
|
||||
case 10:
|
||||
return "underline"; // UNDERLINE
|
||||
case 11:
|
||||
return "squiggly"; // SQUIGGLY
|
||||
case 12:
|
||||
return "strikeout"; // STRIKEOUT
|
||||
case 13:
|
||||
return "stamp"; // STAMP
|
||||
case 15:
|
||||
return "ink"; // INK
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const { selectedAnn, setSelectedAnn, setSelectedAnnId } =
|
||||
useAnnotationSelection({
|
||||
annotationApiRef,
|
||||
deriveToolFromAnnotation,
|
||||
activeToolRef,
|
||||
setActiveTool,
|
||||
setSelectedTextDraft,
|
||||
setSelectedFontSize,
|
||||
setInkWidth,
|
||||
setShapeThickness,
|
||||
setTextColor,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setFreehandHighlighterWidth,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
});
|
||||
|
||||
const steps =
|
||||
allFiles.length === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: t("annotation.title", "Annotate"),
|
||||
isCollapsed: false,
|
||||
onCollapsedClick: undefined,
|
||||
content: (
|
||||
<AnnotationPanel
|
||||
activeTool={activeTool}
|
||||
activateAnnotationTool={activateAnnotationTool}
|
||||
styleState={styleState}
|
||||
styleActions={styleActions}
|
||||
getActiveColor={getActiveColor}
|
||||
buildToolOptions={buildToolOptions}
|
||||
deriveToolFromAnnotation={deriveToolFromAnnotation}
|
||||
selectedAnn={selectedAnn}
|
||||
selectedTextDraft={selectedTextDraft}
|
||||
setSelectedTextDraft={setSelectedTextDraft}
|
||||
selectedFontSize={selectedFontSize}
|
||||
setSelectedFontSize={setSelectedFontSize}
|
||||
annotationApiRef={annotationApiRef}
|
||||
signatureApiRef={signatureApiRef}
|
||||
viewerContext={viewerContext}
|
||||
setPlacementMode={setPlacementMode}
|
||||
setSignatureConfig={setSignatureConfig}
|
||||
computeStampDisplaySize={computeStampDisplaySize}
|
||||
stampImageData={stampImageData}
|
||||
setStampImageData={setStampImageData}
|
||||
stampImageSize={stampImageSize}
|
||||
setStampImageSize={setStampImageSize}
|
||||
setPlacementPreviewSize={setPlacementPreviewSize}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
historyAvailability={historyAvailability}
|
||||
onApplyChanges={handleApplyChanges}
|
||||
applyDisabled={!hasUnsavedChanges}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: allFiles,
|
||||
isCollapsed: false,
|
||||
},
|
||||
steps,
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: {
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
isGeneratingThumbnails: false,
|
||||
downloadUrl: null,
|
||||
downloadFilename: "",
|
||||
isLoading: false,
|
||||
status: "",
|
||||
errorMessage: null,
|
||||
progress: null,
|
||||
executeOperation: async () => {},
|
||||
resetResults: () => {},
|
||||
clearError: () => {},
|
||||
cancelOperation: () => {},
|
||||
undoOperation: async () => {},
|
||||
},
|
||||
title: "",
|
||||
onFileClick: () => {},
|
||||
onUndo: () => {},
|
||||
},
|
||||
forceStepNumbers: true,
|
||||
});
|
||||
};
|
||||
|
||||
export default Annotate;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps } from "@app/types/tool";
|
||||
|
||||
import { useAutoRenameParameters } from "@app/hooks/tools/autoRename/useAutoRenameParameters";
|
||||
import { useAutoRenameOperation } from "@app/hooks/tools/autoRename/useAutoRenameOperation";
|
||||
import { useAutoRenameTips } from "@app/components/tooltips/useAutoRenameTips";
|
||||
|
||||
const AutoRename = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const autoRenameTips = useAutoRenameTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"auto-rename-pdf-file",
|
||||
useAutoRenameParameters,
|
||||
useAutoRenameOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("auto-rename.settings.title", "About"),
|
||||
isCollapsed: false,
|
||||
tooltip: autoRenameTips,
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("auto-rename.submit", "Auto Rename"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("auto-rename.results.title", "Auto-Rename Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default AutoRename;
|
||||
@@ -0,0 +1,281 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { createFilesToolStep } from "@app/components/tools/shared/FilesToolStep";
|
||||
import AutomationSelection from "@app/components/tools/automate/AutomationSelection";
|
||||
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
|
||||
import AutomationRun from "@app/components/tools/automate/AutomationRun";
|
||||
|
||||
import { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation";
|
||||
import { BaseToolProps } from "@app/types/tool";
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { useSavedAutomations } from "@app/hooks/tools/automate/useSavedAutomations";
|
||||
import {
|
||||
AutomationConfig,
|
||||
AutomationStepData,
|
||||
AutomationMode,
|
||||
AutomationStep,
|
||||
} from "@app/types/automation";
|
||||
import { AUTOMATION_STEPS } from "@app/constants/automation";
|
||||
|
||||
const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
const { actions } = useNavigationActions();
|
||||
const { registerToolReset } = useToolWorkflow();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<AutomationStep>(
|
||||
AUTOMATION_STEPS.SELECTION,
|
||||
);
|
||||
const [stepData, setStepData] = useState<AutomationStepData>({
|
||||
step: AUTOMATION_STEPS.SELECTION,
|
||||
});
|
||||
|
||||
const automateOperation = useAutomateOperation();
|
||||
const { regularTools: toolRegistry } = useToolRegistry();
|
||||
const hasResults =
|
||||
automateOperation.files.length > 0 ||
|
||||
automateOperation.downloadUrl !== null;
|
||||
const {
|
||||
savedAutomations,
|
||||
deleteAutomation,
|
||||
refreshAutomations,
|
||||
copyFromSuggested,
|
||||
importAutomation,
|
||||
} = useSavedAutomations();
|
||||
|
||||
// Use ref to store the latest reset function to avoid closure issues
|
||||
const resetFunctionRef = React.useRef<() => void>(null);
|
||||
|
||||
// Update ref with latest reset function
|
||||
resetFunctionRef.current = () => {
|
||||
automateOperation.resetResults();
|
||||
automateOperation.clearError();
|
||||
setCurrentStep(AUTOMATION_STEPS.SELECTION);
|
||||
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
await automateOperation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
// Register reset function with the tool workflow context - only once on mount
|
||||
useEffect(() => {
|
||||
const stableResetFunction = () => {
|
||||
if (resetFunctionRef.current) {
|
||||
resetFunctionRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
registerToolReset("automate", stableResetFunction);
|
||||
}, [registerToolReset]); // Only depend on registerToolReset which should be stable
|
||||
|
||||
const handleStepChange = (data: AutomationStepData) => {
|
||||
// If navigating away from run step, reset automation results
|
||||
if (
|
||||
currentStep === AUTOMATION_STEPS.RUN &&
|
||||
data.step !== AUTOMATION_STEPS.RUN
|
||||
) {
|
||||
automateOperation.resetResults();
|
||||
}
|
||||
|
||||
// If navigating to selection step, always clear results
|
||||
if (data.step === AUTOMATION_STEPS.SELECTION) {
|
||||
automateOperation.resetResults();
|
||||
automateOperation.clearError();
|
||||
}
|
||||
|
||||
// If navigating to run step with a different automation, reset results
|
||||
if (
|
||||
data.step === AUTOMATION_STEPS.RUN &&
|
||||
data.automation &&
|
||||
stepData.automation &&
|
||||
data.automation.id !== stepData.automation.id
|
||||
) {
|
||||
automateOperation.resetResults();
|
||||
}
|
||||
|
||||
setStepData(data);
|
||||
setCurrentStep(data.step);
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
// Reset automation results when completing
|
||||
automateOperation.resetResults();
|
||||
|
||||
// Reset to selection step
|
||||
setCurrentStep(AUTOMATION_STEPS.SELECTION);
|
||||
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||
onComplete?.([]); // Pass empty array since automation creation doesn't produce files
|
||||
};
|
||||
|
||||
const renderCurrentStep = () => {
|
||||
switch (currentStep) {
|
||||
case AUTOMATION_STEPS.SELECTION:
|
||||
return (
|
||||
<AutomationSelection
|
||||
savedAutomations={savedAutomations}
|
||||
onCreateNew={() =>
|
||||
handleStepChange({
|
||||
step: AUTOMATION_STEPS.CREATION,
|
||||
mode: AutomationMode.CREATE,
|
||||
})
|
||||
}
|
||||
onRun={(automation: AutomationConfig) =>
|
||||
handleStepChange({ step: AUTOMATION_STEPS.RUN, automation })
|
||||
}
|
||||
onEdit={(automation: AutomationConfig) =>
|
||||
handleStepChange({
|
||||
step: AUTOMATION_STEPS.CREATION,
|
||||
mode: AutomationMode.EDIT,
|
||||
automation,
|
||||
})
|
||||
}
|
||||
onDelete={async (automation: AutomationConfig) => {
|
||||
try {
|
||||
await deleteAutomation(automation.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete automation:", error);
|
||||
onError?.(`Failed to delete automation: ${automation.name}`);
|
||||
}
|
||||
}}
|
||||
onCopyFromSuggested={async (suggestedAutomation) => {
|
||||
try {
|
||||
await copyFromSuggested(suggestedAutomation);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy suggested automation:", error);
|
||||
onError?.(
|
||||
`Failed to copy automation: ${suggestedAutomation.name}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onImportAutomation={importAutomation}
|
||||
onImportError={(message) => onError?.(message)}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
);
|
||||
|
||||
case AUTOMATION_STEPS.CREATION:
|
||||
if (!stepData.mode) {
|
||||
console.error("Creation mode is undefined");
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AutomationCreation
|
||||
mode={stepData.mode}
|
||||
existingAutomation={stepData.automation}
|
||||
onBack={() =>
|
||||
handleStepChange({ step: AUTOMATION_STEPS.SELECTION })
|
||||
}
|
||||
onComplete={() => {
|
||||
refreshAutomations();
|
||||
handleStepChange({ step: AUTOMATION_STEPS.SELECTION });
|
||||
}}
|
||||
toolRegistry={toolRegistry}
|
||||
/>
|
||||
);
|
||||
|
||||
case AUTOMATION_STEPS.RUN:
|
||||
if (!stepData.automation) {
|
||||
console.error("Automation config is undefined");
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AutomationRun
|
||||
automation={stepData.automation}
|
||||
onComplete={handleComplete}
|
||||
automateOperation={automateOperation}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>{t("automate.invalidStep", "Invalid step")}</div>;
|
||||
}
|
||||
};
|
||||
|
||||
const createStep = (
|
||||
title: string,
|
||||
props: any,
|
||||
content?: React.ReactNode,
|
||||
) => ({
|
||||
title,
|
||||
...props,
|
||||
content,
|
||||
});
|
||||
|
||||
// Always create files step to avoid conditional hook calls
|
||||
const filesStep = createFilesToolStep(createStep, {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
});
|
||||
|
||||
const automationSteps = [
|
||||
createStep(
|
||||
t("automate.selection.title", "Automation Selection"),
|
||||
{
|
||||
isVisible: true,
|
||||
isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION,
|
||||
onCollapsedClick: () => {
|
||||
// Clear results when clicking back to selection
|
||||
automateOperation.resetResults();
|
||||
setCurrentStep(AUTOMATION_STEPS.SELECTION);
|
||||
setStepData({ step: AUTOMATION_STEPS.SELECTION });
|
||||
},
|
||||
},
|
||||
currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null,
|
||||
),
|
||||
|
||||
createStep(
|
||||
stepData.mode === AutomationMode.EDIT
|
||||
? t("automate.creation.editTitle", "Edit Automation")
|
||||
: t("automate.creation.createTitle", "Create Automation"),
|
||||
{
|
||||
isVisible: currentStep === AUTOMATION_STEPS.CREATION,
|
||||
isCollapsed: false,
|
||||
},
|
||||
currentStep === AUTOMATION_STEPS.CREATION ? renderCurrentStep() : null,
|
||||
),
|
||||
|
||||
// Files step - only visible during run mode
|
||||
{
|
||||
...filesStep,
|
||||
isVisible: currentStep === AUTOMATION_STEPS.RUN,
|
||||
},
|
||||
|
||||
// Run step
|
||||
createStep(
|
||||
t("automate.run.title", "Run Automation"),
|
||||
{
|
||||
isVisible: currentStep === AUTOMATION_STEPS.RUN,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
currentStep === AUTOMATION_STEPS.RUN ? renderCurrentStep() : null,
|
||||
),
|
||||
];
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: currentStep === AUTOMATION_STEPS.RUN ? selectedFiles : [],
|
||||
isCollapsed: currentStep !== AUTOMATION_STEPS.RUN || hasResults,
|
||||
isVisible: false, // Hide the default files step since we add our own
|
||||
},
|
||||
steps: automationSteps,
|
||||
review: {
|
||||
isVisible: hasResults && currentStep === AUTOMATION_STEPS.RUN,
|
||||
operation: automateOperation,
|
||||
title: t("automate.reviewTitle", "Automation Results"),
|
||||
onFileClick: (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
actions.setWorkbench("viewer");
|
||||
},
|
||||
onUndo: handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Automate;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import BookletImpositionSettings from "@app/components/tools/bookletImposition/BookletImpositionSettings";
|
||||
import { useBookletImpositionParameters } from "@app/hooks/tools/bookletImposition/useBookletImpositionParameters";
|
||||
import { useBookletImpositionOperation } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useBookletImpositionTips } from "@app/components/tooltips/useBookletImpositionTips";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const BookletImposition = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"bookletImposition",
|
||||
useBookletImpositionParameters,
|
||||
useBookletImpositionOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const bookletTips = useBookletImpositionTips();
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: bookletTips,
|
||||
content: (
|
||||
<BookletImpositionSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("bookletImposition.submit", "Create Booklet"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("bookletImposition.title", "Booklet Imposition Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default BookletImposition as ToolComponent;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import CertificateTypeSettings from "@app/components/tools/certSign/CertificateTypeSettings";
|
||||
import CertificateFormatSettings from "@app/components/tools/certSign/CertificateFormatSettings";
|
||||
import CertificateFilesSettings from "@app/components/tools/certSign/CertificateFilesSettings";
|
||||
import SignatureAppearanceSettings from "@app/components/tools/certSign/SignatureAppearanceSettings";
|
||||
import { useCertSignParameters } from "@app/hooks/tools/certSign/useCertSignParameters";
|
||||
import { useCertSignOperation } from "@app/hooks/tools/certSign/useCertSignOperation";
|
||||
import { useCertificateTypeTips } from "@app/components/tooltips/useCertificateTypeTips";
|
||||
import { useSignatureAppearanceTips } from "@app/components/tooltips/useSignatureAppearanceTips";
|
||||
import { useSignModeTips } from "@app/components/tooltips/useSignModeTips";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const CertSign = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"certSign",
|
||||
useCertSignParameters,
|
||||
useCertSignOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const certTypeTips = useCertificateTypeTips();
|
||||
const appearanceTips = useSignatureAppearanceTips();
|
||||
const signModeTips = useSignModeTips();
|
||||
|
||||
// Check if certificate files are configured for appearance step
|
||||
const areCertFilesConfigured = () => {
|
||||
const params = base.params.parameters;
|
||||
|
||||
// Auto mode (server certificate) - always configured
|
||||
if (params.signMode === "AUTO") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Manual mode - check for required files based on cert type
|
||||
switch (params.certType) {
|
||||
case "PEM":
|
||||
return !!(params.privateKeyFile && params.certFile);
|
||||
case "PKCS12":
|
||||
case "PFX":
|
||||
return !!params.p12File;
|
||||
case "JKS":
|
||||
return !!params.jksFile;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
forceStepNumbers: true,
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("certSign.signMode.stepTitle", "Sign Mode"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: signModeTips,
|
||||
content: (
|
||||
<CertificateTypeSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(base.params.parameters.signMode === "MANUAL"
|
||||
? [
|
||||
{
|
||||
title: t("certSign.certTypeStep.stepTitle", "Certificate Format"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: certTypeTips,
|
||||
content: (
|
||||
<CertificateFormatSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(base.params.parameters.signMode === "MANUAL"
|
||||
? [
|
||||
{
|
||||
title: t("certSign.certFiles.stepTitle", "Certificate Files"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
content: (
|
||||
<CertificateFilesSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t("certSign.appearance.stepTitle", "Signature Appearance"),
|
||||
isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(),
|
||||
onCollapsedClick:
|
||||
base.settingsCollapsed || !areCertFilesConfigured()
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: appearanceTips,
|
||||
content: (
|
||||
<SignatureAppearanceSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("certSign.sign.submit", "Sign PDF"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("certSign.sign.results", "Signed PDF"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
CertSign.tool = () => useCertSignOperation;
|
||||
|
||||
export default CertSign as ToolComponent;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import DeleteAllStep from "@app/components/tools/changeMetadata/steps/DeleteAllStep";
|
||||
import StandardMetadataStep from "@app/components/tools/changeMetadata/steps/StandardMetadataStep";
|
||||
import DocumentDatesStep from "@app/components/tools/changeMetadata/steps/DocumentDatesStep";
|
||||
import AdvancedOptionsStep from "@app/components/tools/changeMetadata/steps/AdvancedOptionsStep";
|
||||
import { useChangeMetadataParameters } from "@app/hooks/tools/changeMetadata/useChangeMetadataParameters";
|
||||
import { useChangeMetadataOperation } from "@app/hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||
import { useMetadataExtraction } from "@app/hooks/tools/changeMetadata/useMetadataExtraction";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import {
|
||||
useDeleteAllTips,
|
||||
useStandardMetadataTips,
|
||||
useDocumentDatesTips,
|
||||
useAdvancedOptionsTips,
|
||||
} from "@app/components/tooltips/useChangeMetadataTips";
|
||||
|
||||
enum MetadataStep {
|
||||
NONE = "none",
|
||||
DELETE_ALL = "deleteAll",
|
||||
STANDARD_METADATA = "standardMetadata",
|
||||
DOCUMENT_DATES = "documentDates",
|
||||
ADVANCED_OPTIONS = "advancedOptions",
|
||||
}
|
||||
|
||||
const ChangeMetadata = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Individual tooltips for each step
|
||||
const deleteAllTips = useDeleteAllTips();
|
||||
const standardMetadataTips = useStandardMetadataTips();
|
||||
const documentDatesTips = useDocumentDatesTips();
|
||||
const advancedOptionsTips = useAdvancedOptionsTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"changeMetadata",
|
||||
useChangeMetadataParameters,
|
||||
useChangeMetadataOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
// Extract metadata from uploaded files
|
||||
const { isExtractingMetadata } = useMetadataExtraction(base.params);
|
||||
|
||||
// Accordion step management
|
||||
const accordion = useAccordionSteps<MetadataStep>({
|
||||
noneValue: MetadataStep.NONE,
|
||||
initialStep: MetadataStep.DELETE_ALL,
|
||||
stateConditions: {
|
||||
hasFiles: base.hasFiles,
|
||||
hasResults: base.hasResults,
|
||||
},
|
||||
afterResults: base.handleSettingsReset,
|
||||
});
|
||||
|
||||
// Create step objects
|
||||
const createStandardMetadataStep = () => ({
|
||||
title: t("changeMetadata.standardFields.title", "Standard Fields"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.STANDARD_METADATA),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(MetadataStep.STANDARD_METADATA),
|
||||
tooltip: standardMetadataTips,
|
||||
content: (
|
||||
<StandardMetadataStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const createDocumentDatesStep = () => ({
|
||||
title: t("changeMetadata.dates.title", "Date Fields"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.DOCUMENT_DATES),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(MetadataStep.DOCUMENT_DATES),
|
||||
tooltip: documentDatesTips,
|
||||
content: (
|
||||
<DocumentDatesStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
const createAdvancedOptionsStep = () => ({
|
||||
title: t("changeMetadata.advanced.title", "Advanced Options"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.ADVANCED_OPTIONS),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(MetadataStep.ADVANCED_OPTIONS),
|
||||
tooltip: advancedOptionsTips,
|
||||
content: (
|
||||
<AdvancedOptionsStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
addCustomMetadata={base.params.addCustomMetadata}
|
||||
removeCustomMetadata={base.params.removeCustomMetadata}
|
||||
updateCustomMetadata={base.params.updateCustomMetadata}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Build steps array based on deleteAll state
|
||||
const buildSteps = () => {
|
||||
const steps = [
|
||||
{
|
||||
title: t("changeMetadata.deleteAll.label", "Remove Existing Metadata"),
|
||||
isCollapsed: accordion.getCollapsedState(MetadataStep.DELETE_ALL),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(MetadataStep.DELETE_ALL),
|
||||
tooltip: deleteAllTips,
|
||||
content: (
|
||||
<DeleteAllStep
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading || isExtractingMetadata}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!base.params.parameters.deleteAll) {
|
||||
steps.push(
|
||||
createStandardMetadataStep(),
|
||||
createDocumentDatesStep(),
|
||||
createAdvancedOptionsStep(),
|
||||
);
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: buildSteps(),
|
||||
executeButton: {
|
||||
text: t("changeMetadata.submit", "Update Metadata"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("changeMetadata.results.title", "Updated PDFs"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ChangeMetadata as ToolComponent;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import ChangePermissionsSettings from "@app/components/tools/changePermissions/ChangePermissionsSettings";
|
||||
import { useChangePermissionsParameters } from "@app/hooks/tools/changePermissions/useChangePermissionsParameters";
|
||||
import { useChangePermissionsOperation } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||
import { useChangePermissionsTips } from "@app/components/tooltips/useChangePermissionsTips";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const ChangePermissions = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const changePermissionsTips = useChangePermissionsTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"changePermissions",
|
||||
useChangePermissionsParameters,
|
||||
useChangePermissionsOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("changePermissions.title", "Document Permissions"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: changePermissionsTips,
|
||||
content: (
|
||||
<ChangePermissionsSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("changePermissions.submit", "Change Permissions"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("changePermissions.results.title", "Modified PDFs"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
ChangePermissions.tool = () => useChangePermissionsOperation;
|
||||
|
||||
export default ChangePermissions as ToolComponent;
|
||||
@@ -0,0 +1,608 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CompareRoundedIcon from "@mui/icons-material/CompareRounded";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Modal,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import SwapVertRoundedIcon from "@mui/icons-material/SwapVertRounded";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import {
|
||||
useCompareParameters,
|
||||
defaultParameters as compareDefaultParameters,
|
||||
} from "@app/hooks/tools/compare/useCompareParameters";
|
||||
import {
|
||||
useCompareOperation,
|
||||
CompareOperationHook,
|
||||
} from "@app/hooks/tools/compare/useCompareOperation";
|
||||
import CompareWorkbenchView from "@app/components/tools/compare/CompareWorkbenchView";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type { StirlingFile } from "@app/types/fileContext";
|
||||
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
|
||||
import type { CompareWorkbenchData } from "@app/types/compare";
|
||||
import { getDefaultWorkbench } from "@app/types/workbench";
|
||||
import { truncateCenter } from "@app/utils/textUtils";
|
||||
import {
|
||||
FileSelectorPicker,
|
||||
FileSelectorResult,
|
||||
} from "@app/components/shared/FileSelectorPicker";
|
||||
import "@app/components/tools/compare/compareView.css";
|
||||
|
||||
const CUSTOM_VIEW_ID = "compareWorkbenchView";
|
||||
const CUSTOM_WORKBENCH_ID = "custom:compareWorkbenchView" as const;
|
||||
|
||||
const Compare = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const base = useBaseTool(
|
||||
"compare",
|
||||
useCompareParameters,
|
||||
useCompareOperation,
|
||||
props,
|
||||
{
|
||||
minFiles: 0,
|
||||
ignoreViewerScope: true,
|
||||
},
|
||||
);
|
||||
|
||||
const operation = base.operation as CompareOperationHook;
|
||||
const params = base.params.parameters;
|
||||
|
||||
const compareIcon = useMemo(
|
||||
() => <CompareRoundedIcon fontSize="small" />,
|
||||
[],
|
||||
);
|
||||
const [swapConfirmOpen, setSwapConfirmOpen] = useState(false);
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
|
||||
// Slot state — files loaded directly for comparison, never added to workbench
|
||||
const [baseSlot, setBaseSlot] = useState<FileSelectorResult | null>(null);
|
||||
const [compSlot, setCompSlot] = useState<FileSelectorResult | null>(null);
|
||||
|
||||
// Sync params fileIds from slots (needed for operation result matching)
|
||||
useEffect(() => {
|
||||
base.params.setParameters((prev) => ({
|
||||
...prev,
|
||||
baseFileId: baseSlot?.stirlingFile.fileId ?? null,
|
||||
comparisonFileId: compSlot?.stirlingFile.fileId ?? null,
|
||||
}));
|
||||
}, [baseSlot?.stirlingFile.fileId, compSlot?.stirlingFile.fileId]);
|
||||
|
||||
const performClearSelected = useCallback(() => {
|
||||
try {
|
||||
base.operation.cancelOperation();
|
||||
} catch {
|
||||
console.error("Failed to cancel operation");
|
||||
}
|
||||
try {
|
||||
base.operation.resetResults();
|
||||
} catch {
|
||||
console.error("Failed to reset results");
|
||||
}
|
||||
setBaseSlot(null);
|
||||
setCompSlot(null);
|
||||
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
|
||||
navigationActions.setWorkbench(getDefaultWorkbench());
|
||||
}, [base.operation, clearCustomWorkbenchViewData, navigationActions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
performClearSelected();
|
||||
};
|
||||
window.addEventListener(
|
||||
"compare:clear-selected",
|
||||
handler as unknown as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"compare:clear-selected",
|
||||
handler as unknown as EventListener,
|
||||
);
|
||||
};
|
||||
}, [performClearSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: CUSTOM_VIEW_ID,
|
||||
workbenchId: CUSTOM_WORKBENCH_ID,
|
||||
label: "Compare view",
|
||||
icon: compareIcon,
|
||||
component: CompareWorkbenchView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unregisterCustomWorkbenchView(CUSTOM_VIEW_ID);
|
||||
};
|
||||
// Register once only
|
||||
}, []);
|
||||
|
||||
// Track workbench data and drive loading/result state transitions
|
||||
const lastProcessedAtRef = useRef<number | null>(null);
|
||||
const lastWorkbenchDataRef = useRef<CompareWorkbenchData | null>(null);
|
||||
|
||||
const updateWorkbenchData = useCallback(
|
||||
(data: CompareWorkbenchData) => {
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
if (
|
||||
previous &&
|
||||
previous.result === data.result &&
|
||||
previous.baseFileId === data.baseFileId &&
|
||||
previous.comparisonFileId === data.comparisonFileId &&
|
||||
previous.isLoading === data.isLoading &&
|
||||
previous.baseLocalFile === data.baseLocalFile &&
|
||||
previous.comparisonLocalFile === data.comparisonLocalFile
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastWorkbenchDataRef.current = data;
|
||||
setCustomWorkbenchViewData(CUSTOM_VIEW_ID, data);
|
||||
},
|
||||
[setCustomWorkbenchViewData],
|
||||
);
|
||||
|
||||
const prepareWorkbenchForRun = useCallback(
|
||||
(
|
||||
baseId: FileId | null,
|
||||
compId: FileId | null,
|
||||
baseFile: StirlingFile | null,
|
||||
comparisonFile: StirlingFile | null,
|
||||
) => {
|
||||
if (!baseId || !compId) return;
|
||||
|
||||
updateWorkbenchData({
|
||||
result: null,
|
||||
baseFileId: baseId,
|
||||
comparisonFileId: compId,
|
||||
baseLocalFile: baseFile,
|
||||
comparisonLocalFile: comparisonFile,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
lastProcessedAtRef.current = null;
|
||||
},
|
||||
[updateWorkbenchData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const baseFileId = params.baseFileId as FileId | null;
|
||||
const comparisonFileId = params.comparisonFileId as FileId | null;
|
||||
|
||||
if (!baseFileId || !comparisonFileId) {
|
||||
lastProcessedAtRef.current = null;
|
||||
lastWorkbenchDataRef.current = null;
|
||||
clearCustomWorkbenchViewData(CUSTOM_VIEW_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = operation.result;
|
||||
const processedAt = result?.totals.processedAt ?? null;
|
||||
|
||||
if (
|
||||
result &&
|
||||
processedAt !== null &&
|
||||
processedAt !== lastProcessedAtRef.current &&
|
||||
result.base.fileId === baseFileId &&
|
||||
result.comparison.fileId === comparisonFileId
|
||||
) {
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
updateWorkbenchData({
|
||||
result,
|
||||
baseFileId,
|
||||
comparisonFileId,
|
||||
baseLocalFile:
|
||||
baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
|
||||
comparisonLocalFile:
|
||||
compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
|
||||
isLoading: false,
|
||||
});
|
||||
lastProcessedAtRef.current = processedAt;
|
||||
return;
|
||||
}
|
||||
|
||||
if (base.operation.isLoading) {
|
||||
const previous = lastWorkbenchDataRef.current;
|
||||
updateWorkbenchData({
|
||||
result: null,
|
||||
baseFileId,
|
||||
comparisonFileId,
|
||||
baseLocalFile:
|
||||
baseSlot?.stirlingFile ?? previous?.baseLocalFile ?? null,
|
||||
comparisonLocalFile:
|
||||
compSlot?.stirlingFile ?? previous?.comparisonLocalFile ?? null,
|
||||
isLoading: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}, [
|
||||
base.operation.isLoading,
|
||||
baseSlot,
|
||||
clearCustomWorkbenchViewData,
|
||||
compSlot,
|
||||
operation.result,
|
||||
params.baseFileId,
|
||||
params.comparisonFileId,
|
||||
updateWorkbenchData,
|
||||
]);
|
||||
|
||||
const handleExecuteCompare = useCallback(async () => {
|
||||
if (!baseSlot || !compSlot) return;
|
||||
const baseId = baseSlot.stirlingFile.fileId;
|
||||
const compId = compSlot.stirlingFile.fileId;
|
||||
const selected: StirlingFile[] = [
|
||||
baseSlot.stirlingFile,
|
||||
compSlot.stirlingFile,
|
||||
];
|
||||
|
||||
prepareWorkbenchForRun(
|
||||
baseId,
|
||||
compId,
|
||||
baseSlot.stirlingFile,
|
||||
compSlot.stirlingFile,
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
|
||||
});
|
||||
|
||||
await operation.executeOperation(
|
||||
{ ...params, baseFileId: baseId, comparisonFileId: compId },
|
||||
selected,
|
||||
);
|
||||
}, [
|
||||
baseSlot,
|
||||
compSlot,
|
||||
navigationActions,
|
||||
operation,
|
||||
params,
|
||||
prepareWorkbenchForRun,
|
||||
]);
|
||||
|
||||
const performSwap = useCallback(() => {
|
||||
if (!baseSlot || !compSlot) return;
|
||||
const newBase = compSlot;
|
||||
const newComp = baseSlot;
|
||||
setBaseSlot(newBase);
|
||||
setCompSlot(newComp);
|
||||
if (operation.result) {
|
||||
const baseId = newBase.stirlingFile.fileId;
|
||||
const compId = newComp.stirlingFile.fileId;
|
||||
const selected: StirlingFile[] = [
|
||||
newBase.stirlingFile,
|
||||
newComp.stirlingFile,
|
||||
];
|
||||
prepareWorkbenchForRun(
|
||||
baseId,
|
||||
compId,
|
||||
newBase.stirlingFile,
|
||||
newComp.stirlingFile,
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
navigationActions.setWorkbench(CUSTOM_WORKBENCH_ID);
|
||||
});
|
||||
void operation.executeOperation(
|
||||
{ ...params, baseFileId: baseId, comparisonFileId: compId },
|
||||
selected,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
baseSlot,
|
||||
compSlot,
|
||||
navigationActions,
|
||||
operation,
|
||||
params,
|
||||
prepareWorkbenchForRun,
|
||||
]);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!baseSlot || !compSlot) return;
|
||||
if (operation.result) {
|
||||
setSwapConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
performSwap();
|
||||
}, [baseSlot, compSlot, operation.result, performSwap]);
|
||||
|
||||
const clearSlot = useCallback((role: "base" | "comparison") => {
|
||||
if (role === "base") setBaseSlot(null);
|
||||
else setCompSlot(null);
|
||||
}, []);
|
||||
|
||||
const renderSlot = useCallback(
|
||||
(role: "base" | "comparison") => {
|
||||
const slot = role === "base" ? baseSlot : compSlot;
|
||||
const otherSlot = role === "base" ? compSlot : baseSlot;
|
||||
const stub = slot?.stub;
|
||||
|
||||
if (stub) {
|
||||
const dateMs = (stub.lastModified || stub.createdAt) ?? null;
|
||||
const dateText = dateMs
|
||||
? new Date(dateMs).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
})
|
||||
: "";
|
||||
const pageCount = stub.processedFile?.totalPages || null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-testid={`compare-slot-${role}`}
|
||||
data-slot-state="filled"
|
||||
data-slot-filename={stub?.name}
|
||||
style={{
|
||||
border: "1px solid var(--border-default)",
|
||||
borderRadius: "var(--radius-md)",
|
||||
padding: "0.75rem 1rem",
|
||||
background: "var(--bg-surface)",
|
||||
width: "100%",
|
||||
minHeight: "9rem",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
onClick={() => clearSlot(role)}
|
||||
aria-label={t("compare.clearSlot", "Remove file")}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<Group align="flex-start" wrap="nowrap" gap="md">
|
||||
<Box style={{ alignSelf: "center" }}>
|
||||
<DocumentThumbnail
|
||||
file={stub}
|
||||
thumbnail={stub.thumbnailUrl || null}
|
||||
/>
|
||||
</Box>
|
||||
<Stack style={{ minWidth: 0, overflow: "hidden", flex: 1 }}>
|
||||
<Text fw={600} title={stub.name}>
|
||||
{truncateCenter(stub.name || "", 50)}
|
||||
</Text>
|
||||
{pageCount && dateText && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{pageCount} {t("compare.pages", "pages")}
|
||||
<br />
|
||||
{dateText}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isDisabled = role === "comparison" && !baseSlot;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`compare-slot-${role}`}
|
||||
data-slot-state="empty"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<FileSelectorPicker
|
||||
testId={`compare-slot-${role}-add`}
|
||||
placeholder={
|
||||
isDisabled
|
||||
? t(
|
||||
"compare.edited.selectBaseFirst",
|
||||
"Select original PDF first",
|
||||
)
|
||||
: t(
|
||||
role === "base"
|
||||
? "compare.original.placeholder"
|
||||
: "compare.edited.placeholder",
|
||||
role === "base"
|
||||
? "Select the original PDF"
|
||||
: "Select the edited PDF",
|
||||
)
|
||||
}
|
||||
excludeIds={
|
||||
otherSlot ? [otherSlot.stirlingFile.fileId as string] : []
|
||||
}
|
||||
disabled={isDisabled}
|
||||
onSelect={(result: FileSelectorResult) => {
|
||||
if (role === "base") setBaseSlot(result);
|
||||
else setCompSlot(result);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[baseSlot, compSlot, clearSlot, t],
|
||||
);
|
||||
const canExecute = Boolean(
|
||||
baseSlot &&
|
||||
compSlot &&
|
||||
baseSlot.stirlingFile.fileId !== compSlot.stirlingFile.fileId &&
|
||||
!base.operation.isLoading &&
|
||||
base.endpointEnabled !== false,
|
||||
);
|
||||
|
||||
const hasBothSelected = Boolean(baseSlot && compSlot);
|
||||
const hasAnySelected = Boolean(baseSlot || compSlot);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t(
|
||||
"compare.selection.originalEditedTitle",
|
||||
"Select Original and Edited PDFs",
|
||||
),
|
||||
isVisible: true,
|
||||
content: (
|
||||
<Stack gap="sm" className="compare-step-selection">
|
||||
<div className="compare-step-selection__clear-row">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => setClearConfirmOpen(true)}
|
||||
disabled={!hasAnySelected}
|
||||
styles={{ root: { textDecoration: "underline" } }}
|
||||
style={{
|
||||
background: !hasAnySelected ? "transparent" : undefined,
|
||||
color: !hasAnySelected
|
||||
? "var(--spdf-clear-disabled-text)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{t("compare.clearSelected", "Clear selected")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Text fw={700} size="sm" style={{ margin: 0 }}>
|
||||
{t("compare.original.label", "Original PDF")}
|
||||
</Text>
|
||||
|
||||
<div className="compare-step-selection__thumbs-row">
|
||||
<Stack gap="sm" className="compare-step-selection__thumbs-col">
|
||||
{renderSlot("base")}
|
||||
<Text fw={700} size="sm" style={{ margin: 0 }}>
|
||||
{t("compare.edited.label", "Edited PDF")}
|
||||
</Text>
|
||||
{renderSlot("comparison")}
|
||||
</Stack>
|
||||
|
||||
{hasBothSelected && (
|
||||
<button
|
||||
type="button"
|
||||
className="compare-step-selection__swap"
|
||||
onClick={handleSwap}
|
||||
disabled={base.operation.isLoading}
|
||||
aria-label={t("compare.swap.label", "Swap")}
|
||||
>
|
||||
<SwapVertRoundedIcon
|
||||
className="compare-step-selection__swap-icon"
|
||||
fontSize="inherit"
|
||||
/>
|
||||
<span className="compare-step-selection__swap-label">
|
||||
{t("compare.swap.label", "Swap")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
opened={swapConfirmOpen}
|
||||
onClose={() => setSwapConfirmOpen(false)}
|
||||
title={t("compare.swap.confirmTitle", "Re-run comparison?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t(
|
||||
"compare.swap.confirmBody",
|
||||
"This will rerun the tool. Are you sure you want to swap the order of Original and Edited?",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => setSwapConfirmOpen(false)}
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => {
|
||||
setSwapConfirmOpen(false);
|
||||
performSwap();
|
||||
}}
|
||||
>
|
||||
{t("compare.swap.confirm", "Swap and Re-run")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={clearConfirmOpen}
|
||||
onClose={() => setClearConfirmOpen(false)}
|
||||
title={t("compare.clear.confirmTitle", "Clear selected PDFs?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t(
|
||||
"compare.clear.confirmBody",
|
||||
"This will clear the current file selections.",
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => setClearConfirmOpen(false)}
|
||||
>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => {
|
||||
setClearConfirmOpen(false);
|
||||
performClearSelected();
|
||||
}}
|
||||
>
|
||||
{t("compare.clear.confirm", "Clear")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("compare.cta", "Compare"),
|
||||
loadingText: t("compare.loading", "Comparing..."),
|
||||
onClick: handleExecuteCompare,
|
||||
disabled: !canExecute,
|
||||
// Explicitly null so the noFiles gate is bypassed — Compare manages its own slot state
|
||||
disabledReason:
|
||||
base.endpointEnabled === false ? "endpointUnavailable" : null,
|
||||
testId: "compare-execute",
|
||||
disableScopeHints: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: t("compare.review.title", "Comparison Result"),
|
||||
onUndo: base.operation.undoOperation,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const CompareTool = Compare as ToolComponent;
|
||||
CompareTool.tool = () => useCompareOperation;
|
||||
CompareTool.getDefaultParameters = () => ({ ...compareDefaultParameters });
|
||||
|
||||
export default CompareTool;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import CompressSettings from "@app/components/tools/compress/CompressSettings";
|
||||
import { useCompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
|
||||
import { useCompressOperation } from "@app/hooks/tools/compress/useCompressOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useCompressTips } from "@app/components/tooltips/useCompressTips";
|
||||
|
||||
const Compress = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const compressTips = useCompressTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"compress",
|
||||
useCompressParameters,
|
||||
useCompressOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: compressTips,
|
||||
content: (
|
||||
<CompressSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("compress.submit", "Compress"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("compress.title", "Compression Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Compress as ToolComponent;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
import ConvertSettings from "@app/components/tools/convert/ConvertSettings";
|
||||
|
||||
import { useConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
|
||||
import { useConvertOperation } from "@app/hooks/tools/convert/useConvertOperation";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectors } = useFileState();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const convertParams = useConvertParameters();
|
||||
const convertOperation = useConvertOperation(convertParams.parameters);
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled(convertParams.getEndpointName());
|
||||
|
||||
// Prevent reset immediately after operation completes (when consumeFiles auto-selects outputs)
|
||||
const skipNextSelectionResetRef = useRef(false);
|
||||
const previousSelectionRef = useRef<string>("");
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults =
|
||||
convertOperation.files.length > 0 ||
|
||||
convertOperation.downloadUrl !== null ||
|
||||
!!convertOperation.errorMessage;
|
||||
const settingsCollapsed = hasResults;
|
||||
|
||||
// When operation completes, flag the next selection change to skip reset
|
||||
useEffect(() => {
|
||||
if (hasResults) {
|
||||
skipNextSelectionResetRef.current = true;
|
||||
}
|
||||
}, [hasResults]);
|
||||
|
||||
// Reset results when user manually changes file selection
|
||||
useEffect(() => {
|
||||
const currentSelection = selectedFiles
|
||||
.map((f) => f.fileId)
|
||||
.sort()
|
||||
.join(",");
|
||||
|
||||
if (currentSelection === previousSelectionRef.current) return; // No change
|
||||
|
||||
// Skip reset if this is the auto-selection after operation completed
|
||||
// Don't analyze file types - would change parameters and trigger another reset
|
||||
if (skipNextSelectionResetRef.current) {
|
||||
skipNextSelectionResetRef.current = false;
|
||||
previousSelectionRef.current = currentSelection;
|
||||
return;
|
||||
}
|
||||
|
||||
// User manually selected different files
|
||||
if (selectedFiles.length > 0) {
|
||||
previousSelectionRef.current = currentSelection;
|
||||
convertParams.analyzeFileTypes(selectedFiles);
|
||||
if (hasResults) {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
} else {
|
||||
previousSelectionRef.current = "";
|
||||
if (activeFiles.length === 0) {
|
||||
convertParams.resetParameters();
|
||||
}
|
||||
}
|
||||
}, [selectedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset when user changes conversion parameters (but not during operation)
|
||||
if (!convertOperation.isLoading && !skipNextSelectionResetRef.current) {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}
|
||||
}, [
|
||||
convertParams.parameters.fromExtension,
|
||||
convertParams.parameters.toExtension,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFiles) {
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
}, [hasFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasResults) {
|
||||
setTimeout(scrollToBottom, 100);
|
||||
}
|
||||
}, [hasResults]);
|
||||
|
||||
const handleConvert = async () => {
|
||||
try {
|
||||
await convertOperation.executeOperation(
|
||||
convertParams.parameters,
|
||||
selectedFiles,
|
||||
);
|
||||
if (convertOperation.files && onComplete) {
|
||||
onComplete(convertOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(
|
||||
error instanceof Error ? error.message : "Convert operation failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem("previousMode", "convert");
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
skipNextSelectionResetRef.current = false;
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
await convertOperation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("convert.settings", "Settings"),
|
||||
isCollapsed: settingsCollapsed,
|
||||
onCollapsedClick: settingsCollapsed ? handleSettingsReset : undefined,
|
||||
content: (
|
||||
<ConvertSettings
|
||||
parameters={convertParams.parameters}
|
||||
onParameterChange={convertParams.updateParameter}
|
||||
getAvailableToExtensions={convertParams.getAvailableToExtensions}
|
||||
selectedFiles={selectedFiles}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("convert.convertFiles", "Convert Files"),
|
||||
loadingText: t("convert.converting", "Converting..."),
|
||||
onClick: handleConvert,
|
||||
isVisible: !hasResults,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: convertParams.validateParameters(),
|
||||
testId: "convert-button",
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: convertOperation,
|
||||
title: t("convert.conversionResults", "Conversion Results"),
|
||||
onFileClick: handleThumbnailClick,
|
||||
onUndo: handleUndo,
|
||||
testId: "conversion-results",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
Convert.tool = () => useConvertOperation;
|
||||
|
||||
export default Convert as ToolComponent;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import CropSettings from "@app/components/tools/crop/CropSettings";
|
||||
import { useCropParameters } from "@app/hooks/tools/crop/useCropParameters";
|
||||
import { useCropOperation } from "@app/hooks/tools/crop/useCropOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useCropTooltips } from "@app/components/tooltips/useCropTooltips";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const Crop = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool("crop", useCropParameters, useCropOperation, props);
|
||||
|
||||
const tooltips = useCropTooltips();
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
minFiles: 1,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("crop.steps.selectArea", "Select Crop Area"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.hasResults
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: tooltips,
|
||||
content: (
|
||||
<CropSettings
|
||||
parameters={base.params}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("crop.submit", "Apply Crop"),
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
isVisible: !base.hasResults,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("crop.results.title", "Crop Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Crop as ToolComponent;
|
||||
@@ -0,0 +1,471 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import MenuBookRoundedIcon from "@mui/icons-material/MenuBookRounded";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import EditTableOfContentsWorkbenchView, {
|
||||
EditTableOfContentsWorkbenchViewData,
|
||||
} from "@app/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView";
|
||||
import EditTableOfContentsSettings from "@app/components/tools/editTableOfContents/EditTableOfContentsSettings";
|
||||
import { useEditTableOfContentsParameters } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsParameters";
|
||||
import { useEditTableOfContentsOperation } from "@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import {
|
||||
BookmarkPayload,
|
||||
BookmarkNode,
|
||||
hydrateBookmarkPayload,
|
||||
serializeBookmarkNodes,
|
||||
} from "@app/utils/editTableOfContents";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import {
|
||||
useNavigationActions,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
|
||||
const extractBookmarks = async (file: File): Promise<BookmarkPayload[]> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await apiClient.post(
|
||||
"/api/v1/general/extract-bookmarks",
|
||||
formData,
|
||||
);
|
||||
|
||||
return response.data as BookmarkPayload[];
|
||||
};
|
||||
|
||||
const useStableCallback = <T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
): T => {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
return useMemo(
|
||||
() => ((...args: Parameters<T>) => callbackRef.current(...args)) as T,
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
const EditTableOfContents = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const base = useBaseTool(
|
||||
"edit-table-of-contents",
|
||||
useEditTableOfContentsParameters,
|
||||
useEditTableOfContentsOperation,
|
||||
props,
|
||||
{ minFiles: 1 },
|
||||
);
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { clearSelections } = useFileSelection();
|
||||
const navigationState = useNavigationState();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
|
||||
const WORKBENCH_VIEW_ID = "editTableOfContentsWorkbench";
|
||||
const WORKBENCH_ID = "custom:editTableOfContents" as const;
|
||||
const viewIcon = useMemo(() => <MenuBookRoundedIcon fontSize="small" />, []);
|
||||
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isLoadingBookmarks, setIsLoadingBookmarks] = useState(false);
|
||||
const [lastLoadedFileId, setLastLoadedFileId] = useState<string | null>(null);
|
||||
const hasAutoOpenedWorkbenchRef = useRef(false);
|
||||
|
||||
const selectedFile = base.selectedFiles[0];
|
||||
|
||||
const { setBookmarks } = base.params;
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: WORKBENCH_VIEW_ID,
|
||||
workbenchId: WORKBENCH_ID,
|
||||
label: "Outline workspace",
|
||||
icon: viewIcon,
|
||||
component: EditTableOfContentsWorkbenchView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID);
|
||||
};
|
||||
// Register once; avoid re-registering which clears data mid-flight
|
||||
}, []);
|
||||
|
||||
const loadBookmarksForFile = useCallback(
|
||||
async (file: File, { showToast }: { showToast?: boolean } = {}) => {
|
||||
setIsLoadingBookmarks(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
const payload = await extractBookmarks(file);
|
||||
const bookmarks = hydrateBookmarkPayload(payload);
|
||||
setBookmarks(bookmarks);
|
||||
setLastLoadedFileId((file as any)?.fileId ?? file.name);
|
||||
|
||||
if (showToast) {
|
||||
alert({
|
||||
title: t(
|
||||
"editTableOfContents.messages.loadedTitle",
|
||||
"Bookmarks extracted",
|
||||
),
|
||||
body: t(
|
||||
"editTableOfContents.messages.loadedBody",
|
||||
"Existing bookmarks from the PDF were loaded into the editor.",
|
||||
),
|
||||
alertType: "success",
|
||||
});
|
||||
}
|
||||
|
||||
if (bookmarks.length === 0) {
|
||||
setLoadError(
|
||||
t(
|
||||
"editTableOfContents.messages.noBookmarks",
|
||||
"No bookmarks were found in the selected PDF.",
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load bookmarks", error);
|
||||
setLoadError(
|
||||
t(
|
||||
"editTableOfContents.messages.loadFailed",
|
||||
"Unable to extract bookmarks from the selected PDF.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingBookmarks(false);
|
||||
}
|
||||
},
|
||||
[setBookmarks, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't auto-load bookmarks if we have results - user is viewing the output
|
||||
if (base.hasResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedFile) {
|
||||
setBookmarks([]);
|
||||
setLastLoadedFileId(null);
|
||||
setLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileId = (selectedFile as any)?.fileId ?? selectedFile.name;
|
||||
if (fileId === lastLoadedFileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadBookmarksForFile(selectedFile).catch(() => {
|
||||
// errors handled in hook
|
||||
});
|
||||
}, [
|
||||
selectedFile,
|
||||
lastLoadedFileId,
|
||||
loadBookmarksForFile,
|
||||
setBookmarks,
|
||||
base.hasResults,
|
||||
]);
|
||||
|
||||
const importJsonCallback = async (file: File) => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text) as BookmarkPayload[];
|
||||
setBookmarks(hydrateBookmarkPayload(json));
|
||||
alert({
|
||||
title: t("editTableOfContents.messages.imported", "Bookmarks imported"),
|
||||
body: t(
|
||||
"editTableOfContents.messages.importedBody",
|
||||
"Your JSON outline replaced the current editor contents.",
|
||||
),
|
||||
alertType: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to import JSON bookmarks", error);
|
||||
alert({
|
||||
title: t(
|
||||
"editTableOfContents.messages.invalidJson",
|
||||
"Invalid JSON structure",
|
||||
),
|
||||
body: t(
|
||||
"editTableOfContents.messages.invalidJsonBody",
|
||||
"Please provide a valid bookmark JSON file and try again.",
|
||||
),
|
||||
alertType: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleImportJson = useStableCallback(importJsonCallback);
|
||||
|
||||
const importClipboardCallback = async () => {
|
||||
if (!navigator.clipboard?.readText) {
|
||||
alert({
|
||||
title: t(
|
||||
"editTableOfContents.actions.clipboardUnavailable",
|
||||
"Clipboard access unavailable",
|
||||
),
|
||||
alertType: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const clipboard = await navigator.clipboard.readText();
|
||||
const json = JSON.parse(clipboard) as BookmarkPayload[];
|
||||
setBookmarks(hydrateBookmarkPayload(json));
|
||||
alert({
|
||||
title: t("editTableOfContents.messages.imported", "Bookmarks imported"),
|
||||
body: t(
|
||||
"editTableOfContents.messages.importedClipboard",
|
||||
"Clipboard data replaced the current bookmark list.",
|
||||
),
|
||||
alertType: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to import bookmarks from clipboard", error);
|
||||
alert({
|
||||
title: t(
|
||||
"editTableOfContents.messages.invalidJson",
|
||||
"Invalid JSON structure",
|
||||
),
|
||||
body: t(
|
||||
"editTableOfContents.messages.invalidJsonBody",
|
||||
"Please provide a valid bookmark JSON file and try again.",
|
||||
),
|
||||
alertType: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleImportClipboard = useStableCallback(importClipboardCallback);
|
||||
|
||||
const exportJsonCallback = () => {
|
||||
const data = JSON.stringify(
|
||||
serializeBookmarkNodes(base.params.parameters.bookmarks),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
void downloadFile({ data: blob, filename: "bookmarks.json" });
|
||||
alert({
|
||||
title: t("editTableOfContents.messages.exported", "JSON download ready"),
|
||||
alertType: "success",
|
||||
});
|
||||
};
|
||||
const handleExportJson = useStableCallback(exportJsonCallback);
|
||||
|
||||
const exportClipboardCallback = async () => {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
alert({
|
||||
title: t(
|
||||
"editTableOfContents.actions.clipboardUnavailable",
|
||||
"Clipboard access unavailable",
|
||||
),
|
||||
alertType: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.stringify(
|
||||
serializeBookmarkNodes(base.params.parameters.bookmarks),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
try {
|
||||
await navigator.clipboard.writeText(data);
|
||||
alert({
|
||||
title: t("editTableOfContents.messages.copied", "Copied to clipboard"),
|
||||
body: t(
|
||||
"editTableOfContents.messages.copiedBody",
|
||||
"Bookmark JSON copied successfully.",
|
||||
),
|
||||
alertType: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to copy bookmarks", error);
|
||||
alert({
|
||||
title: t("editTableOfContents.messages.copyFailed", "Copy failed"),
|
||||
alertType: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleExportClipboard = useStableCallback(exportClipboardCallback);
|
||||
|
||||
const clipboardReadAvailable =
|
||||
typeof navigator !== "undefined" && Boolean(navigator.clipboard?.readText);
|
||||
const clipboardWriteAvailable =
|
||||
typeof navigator !== "undefined" && Boolean(navigator.clipboard?.writeText);
|
||||
|
||||
const loadFromSelectedCallback = () => {
|
||||
if (selectedFile) {
|
||||
loadBookmarksForFile(selectedFile, { showToast: true });
|
||||
}
|
||||
};
|
||||
const handleLoadFromSelected = useStableCallback(loadFromSelectedCallback);
|
||||
|
||||
const replaceExistingCallback = (value: boolean) => {
|
||||
base.params.updateParameter("replaceExisting", value);
|
||||
};
|
||||
const handleReplaceExistingChange = useStableCallback(
|
||||
replaceExistingCallback,
|
||||
);
|
||||
|
||||
const bookmarksChangeCallback = (bookmarks: BookmarkNode[]) => {
|
||||
setBookmarks(bookmarks);
|
||||
};
|
||||
const handleBookmarksChange = useStableCallback(bookmarksChangeCallback);
|
||||
|
||||
const executeCallback = () => {
|
||||
void base.handleExecute();
|
||||
};
|
||||
const handleExecute = useStableCallback(executeCallback);
|
||||
|
||||
const undoCallback = () => {
|
||||
base.handleUndo();
|
||||
};
|
||||
const handleUndo = useStableCallback(undoCallback);
|
||||
|
||||
const clearErrorCallback = () => {
|
||||
base.operation.clearError();
|
||||
};
|
||||
const handleClearError = useStableCallback(clearErrorCallback);
|
||||
|
||||
const fileClickCallback = (file: File) => {
|
||||
base.handleThumbnailClick(file);
|
||||
};
|
||||
const handleFileClick = useStableCallback(fileClickCallback);
|
||||
|
||||
const selectFilesCallback = () => {
|
||||
// Clear existing selection first so the new file replaces instead of adds
|
||||
clearSelections();
|
||||
openFilesModal();
|
||||
};
|
||||
const handleSelectFiles = useStableCallback(selectFilesCallback);
|
||||
|
||||
// Always keep workbench data updated
|
||||
useEffect(() => {
|
||||
const data: EditTableOfContentsWorkbenchViewData = {
|
||||
bookmarks: base.params.parameters.bookmarks,
|
||||
selectedFileName: selectedFile?.name,
|
||||
disabled: base.endpointLoading || base.operation.isLoading,
|
||||
files: base.operation.files ?? [],
|
||||
thumbnails: base.operation.thumbnails ?? [],
|
||||
downloadUrl: base.operation.downloadUrl ?? null,
|
||||
downloadFilename: base.operation.downloadFilename ?? null,
|
||||
errorMessage: base.operation.errorMessage ?? null,
|
||||
isGeneratingThumbnails: base.operation.isGeneratingThumbnails,
|
||||
isExecuteDisabled:
|
||||
!selectedFile ||
|
||||
!base.hasFiles ||
|
||||
base.endpointEnabled === false ||
|
||||
base.operation.isLoading ||
|
||||
base.endpointLoading,
|
||||
isExecuting: base.operation.isLoading,
|
||||
onClearError: handleClearError,
|
||||
onBookmarksChange: handleBookmarksChange,
|
||||
onExecute: handleExecute,
|
||||
onUndo: handleUndo,
|
||||
onFileClick: handleFileClick,
|
||||
};
|
||||
|
||||
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, data);
|
||||
}, [
|
||||
WORKBENCH_VIEW_ID,
|
||||
base.endpointEnabled,
|
||||
base.endpointLoading,
|
||||
base.hasFiles,
|
||||
base.operation.downloadFilename,
|
||||
base.operation.downloadUrl,
|
||||
base.operation.errorMessage,
|
||||
base.operation.files,
|
||||
base.operation.isGeneratingThumbnails,
|
||||
base.operation.isLoading,
|
||||
base.operation.thumbnails,
|
||||
base.params.parameters.bookmarks,
|
||||
handleBookmarksChange,
|
||||
handleClearError,
|
||||
handleExecute,
|
||||
handleFileClick,
|
||||
handleUndo,
|
||||
selectedFile,
|
||||
setCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
// Auto-navigate to workbench when tool is selected
|
||||
useEffect(() => {
|
||||
if (navigationState.selectedTool !== "editTableOfContents") {
|
||||
hasAutoOpenedWorkbenchRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAutoOpenedWorkbenchRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasAutoOpenedWorkbenchRef.current = true;
|
||||
// Use timeout to ensure data effect has run first
|
||||
setTimeout(() => {
|
||||
navigationActions.setWorkbench(WORKBENCH_ID);
|
||||
}, 0);
|
||||
}, [navigationActions, navigationState.selectedTool, WORKBENCH_ID]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: true,
|
||||
minFiles: 1,
|
||||
isVisible: false,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("editTableOfContents.settings.title", "Bookmarks & outline"),
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<EditTableOfContentsSettings
|
||||
bookmarks={base.params.parameters.bookmarks}
|
||||
replaceExisting={base.params.parameters.replaceExisting}
|
||||
onReplaceExistingChange={handleReplaceExistingChange}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
onLoadFromPdf={handleLoadFromSelected}
|
||||
onImportJson={handleImportJson}
|
||||
onImportClipboard={handleImportClipboard}
|
||||
onExportJson={handleExportJson}
|
||||
onExportClipboard={handleExportClipboard}
|
||||
isLoading={isLoadingBookmarks}
|
||||
loadError={loadError}
|
||||
canReadClipboard={clipboardReadAvailable}
|
||||
canWriteClipboard={clipboardWriteAvailable}
|
||||
disabled={base.endpointLoading}
|
||||
selectedFileName={selectedFile?.name}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t(
|
||||
"editTableOfContents.results.title",
|
||||
"Updated PDF with bookmarks",
|
||||
),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
(EditTableOfContents as any).tool = () => useEditTableOfContentsOperation;
|
||||
|
||||
export default EditTableOfContents as ToolComponent;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import ExtractImagesSettings from "@app/components/tools/extractImages/ExtractImagesSettings";
|
||||
import { useExtractImagesParameters } from "@app/hooks/tools/extractImages/useExtractImagesParameters";
|
||||
import { useExtractImagesOperation } from "@app/hooks/tools/extractImages/useExtractImagesOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const ExtractImages = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"extractImages",
|
||||
useExtractImagesParameters,
|
||||
useExtractImagesOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("extractImages.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
content: (
|
||||
<ExtractImagesSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("extractImages.submit", "Extract Images"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("extractImages.title", "Extracted Images"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ExtractImages as ToolComponent;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useExtractPagesParameters } from "@app/hooks/tools/extractPages/useExtractPagesParameters";
|
||||
import { useExtractPagesOperation } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
|
||||
import ExtractPagesSettings from "@app/components/tools/extractPages/ExtractPagesSettings";
|
||||
import useExtractPagesTips from "@app/components/tooltips/useExtractPagesTips";
|
||||
|
||||
const ExtractPages = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const tooltipContent = useExtractPagesTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"extract-pages",
|
||||
useExtractPagesParameters,
|
||||
useExtractPagesOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const settingsContent = (
|
||||
<ExtractPagesSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("extractPages.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
content: settingsContent,
|
||||
tooltip: tooltipContent,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("extractPages.submit", "Extract Pages"),
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
isVisible: !base.hasResults,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("extractPages.results.title", "Pages Extracted"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ExtractPages as ToolComponent;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import FlattenSettings from "@app/components/tools/flatten/FlattenSettings";
|
||||
import { useFlattenParameters } from "@app/hooks/tools/flatten/useFlattenParameters";
|
||||
import { useFlattenOperation } from "@app/hooks/tools/flatten/useFlattenOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useFlattenTips } from "@app/components/tooltips/useFlattenTips";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const Flatten = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const flattenTips = useFlattenTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"flatten",
|
||||
useFlattenParameters,
|
||||
useFlattenOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("flatten.options.stepTitle", "Flatten Options"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: flattenTips,
|
||||
content: (
|
||||
<FlattenSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("flatten.submit", "Flatten PDF"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("flatten.results.title", "Flatten Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
Flatten.tool = () => useFlattenOperation;
|
||||
|
||||
export default Flatten as ToolComponent;
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import LinkIcon from "@mui/icons-material/Link";
|
||||
import { Stack, Group, Divider, Text, UnstyledButton } from "@mantine/core";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import {
|
||||
useGetPdfInfoParameters,
|
||||
defaultParameters,
|
||||
} from "@app/hooks/tools/getPdfInfo/useGetPdfInfoParameters";
|
||||
import GetPdfInfoResults from "@app/components/tools/getPdfInfo/GetPdfInfoResults";
|
||||
import {
|
||||
useGetPdfInfoOperation,
|
||||
GetPdfInfoOperationHook,
|
||||
} from "@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation";
|
||||
import GetPdfInfoReportView from "@app/components/tools/getPdfInfo/GetPdfInfoReportView";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
useNavigationActions,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import type { PdfInfoReportData } from "@app/types/getPdfInfo";
|
||||
|
||||
const CHAPTERS = [
|
||||
{
|
||||
id: "summary",
|
||||
labelKey: "getPdfInfo.summary.title",
|
||||
fallback: "PDF Summary",
|
||||
},
|
||||
{
|
||||
id: "metadata",
|
||||
labelKey: "getPdfInfo.sections.metadata",
|
||||
fallback: "Metadata",
|
||||
},
|
||||
{
|
||||
id: "formFields",
|
||||
labelKey: "getPdfInfo.sections.formFields",
|
||||
fallback: "Form Fields",
|
||||
},
|
||||
{
|
||||
id: "basicInfo",
|
||||
labelKey: "getPdfInfo.sections.basicInfo",
|
||||
fallback: "Basic Info",
|
||||
},
|
||||
{
|
||||
id: "documentInfo",
|
||||
labelKey: "getPdfInfo.sections.documentInfo",
|
||||
fallback: "Document Info",
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
labelKey: "getPdfInfo.sections.compliance",
|
||||
fallback: "Compliance",
|
||||
},
|
||||
{
|
||||
id: "encryption",
|
||||
labelKey: "getPdfInfo.sections.encryption",
|
||||
fallback: "Encryption",
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
labelKey: "getPdfInfo.sections.permissions",
|
||||
fallback: "Permissions",
|
||||
},
|
||||
{
|
||||
id: "toc",
|
||||
labelKey: "getPdfInfo.sections.tableOfContents",
|
||||
fallback: "Table of Contents",
|
||||
},
|
||||
{ id: "other", labelKey: "getPdfInfo.sections.other", fallback: "Other" },
|
||||
{
|
||||
id: "perPage",
|
||||
labelKey: "getPdfInfo.sections.perPageInfo",
|
||||
fallback: "Per Page Info",
|
||||
},
|
||||
];
|
||||
|
||||
const GetPdfInfo = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const REPORT_VIEW_ID = "getPdfInfoReport";
|
||||
const REPORT_WORKBENCH_ID = "custom:getPdfInfoReport" as const;
|
||||
const reportIcon = useMemo(() => <PictureAsPdfIcon fontSize="small" />, []);
|
||||
|
||||
const base = useBaseTool(
|
||||
"getPdfInfo",
|
||||
useGetPdfInfoParameters,
|
||||
useGetPdfInfoOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const operation = base.operation as GetPdfInfoOperationHook;
|
||||
const hasResults = operation.results.length > 0;
|
||||
const showResultsStep =
|
||||
hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: REPORT_VIEW_ID,
|
||||
workbenchId: REPORT_WORKBENCH_ID,
|
||||
label: t("getPdfInfo.report.shortTitle", "PDF Information"),
|
||||
icon: reportIcon,
|
||||
component: GetPdfInfoReportView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(REPORT_VIEW_ID);
|
||||
};
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
registerCustomWorkbenchView,
|
||||
reportIcon,
|
||||
t,
|
||||
unregisterCustomWorkbenchView,
|
||||
]);
|
||||
|
||||
const reportData = useMemo<PdfInfoReportData | null>(() => {
|
||||
if (operation.results.length === 0) return null;
|
||||
const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now();
|
||||
return {
|
||||
generatedAt,
|
||||
entries: operation.results,
|
||||
};
|
||||
}, [operation.results]);
|
||||
|
||||
const lastReportGeneratedAtRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (reportData) {
|
||||
setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData);
|
||||
const generatedAt = reportData.generatedAt ?? null;
|
||||
const isNewReport =
|
||||
generatedAt && generatedAt !== lastReportGeneratedAtRef.current;
|
||||
if (isNewReport) {
|
||||
lastReportGeneratedAtRef.current = generatedAt;
|
||||
if (
|
||||
navigationState.selectedTool === "getPdfInfo" &&
|
||||
navigationState.workbench !== REPORT_WORKBENCH_ID
|
||||
) {
|
||||
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
lastReportGeneratedAtRef.current = null;
|
||||
}
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
navigationActions,
|
||||
navigationState.selectedTool,
|
||||
navigationState.workbench,
|
||||
reportData,
|
||||
setCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("getPdfInfo.indexTitle", "Index"),
|
||||
isVisible: Boolean(reportData),
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<Stack gap={0}>
|
||||
{CHAPTERS.map((c, idx) => (
|
||||
<Stack key={c.id} gap={0}>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
if (!reportData) return;
|
||||
setCustomWorkbenchViewData(REPORT_VIEW_ID, {
|
||||
...reportData,
|
||||
scrollTo: c.id,
|
||||
});
|
||||
if (navigationState.workbench !== REPORT_WORKBENCH_ID) {
|
||||
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
padding: "8px 4px",
|
||||
}}
|
||||
>
|
||||
<Group justify="flex-start" gap="sm">
|
||||
<LinkIcon fontSize="small" style={{ opacity: 0.7 }} />
|
||||
<Text size="md" c="dimmed">
|
||||
{t(c.labelKey, c.fallback)}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{idx < CHAPTERS.length - 1 && <Divider my={6} />}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("getPdfInfo.results", "Results"),
|
||||
isVisible: showResultsStep,
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<GetPdfInfoResults
|
||||
operation={operation}
|
||||
isLoading={base.operation.isLoading}
|
||||
errorMessage={base.operation.errorMessage}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("getPdfInfo.submit", "Generate"),
|
||||
loadingText: t("loading", "Loading..."),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
isVisible: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: t("getPdfInfo.results", "Results"),
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const GetPdfInfoTool = GetPdfInfo as ToolComponent;
|
||||
GetPdfInfoTool.tool = () => useGetPdfInfoOperation;
|
||||
GetPdfInfoTool.getDefaultParameters = () => ({ ...defaultParameters });
|
||||
|
||||
export default GetPdfInfoTool;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import MergeSettings from "@app/components/tools/merge/MergeSettings";
|
||||
import MergeFileSorter from "@app/components/tools/merge/MergeFileSorter";
|
||||
import { useMergeParameters } from "@app/hooks/tools/merge/useMergeParameters";
|
||||
import { useMergeOperation } from "@app/hooks/tools/merge/useMergeOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useMergeTips } from "@app/components/tooltips/useMergeTips";
|
||||
import { useFileManagement, useAllFiles } from "@app/contexts/FileContext";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
|
||||
const Merge = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const mergeTips = useMergeTips();
|
||||
|
||||
// File selection hooks for custom sorting
|
||||
const { fileIds, fileStubs } = useAllFiles();
|
||||
const { reorderFiles } = useFileManagement();
|
||||
|
||||
const base = useBaseTool(
|
||||
"merge",
|
||||
useMergeParameters,
|
||||
useMergeOperation,
|
||||
props,
|
||||
{ minFiles: 2, ignoreViewerScope: true },
|
||||
);
|
||||
|
||||
const { workbench } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const isViewerMode = workbench === "viewer";
|
||||
|
||||
const hasAutoSwitchedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isViewerMode && !hasAutoSwitchedRef.current) {
|
||||
hasAutoSwitchedRef.current = true;
|
||||
navActions.setWorkbench("fileEditor");
|
||||
}
|
||||
}, []);
|
||||
const naturalCompare = useCallback((a: string, b: string): number => {
|
||||
const isDigit = (char: string) => char >= "0" && char <= "9";
|
||||
|
||||
const getChunk = (
|
||||
s: string,
|
||||
length: number,
|
||||
marker: number,
|
||||
): { chunk: string; newMarker: number } => {
|
||||
let chunk = "";
|
||||
const c = s.charAt(marker);
|
||||
chunk += c;
|
||||
marker++;
|
||||
|
||||
if (isDigit(c)) {
|
||||
while (marker < length && isDigit(s.charAt(marker))) {
|
||||
chunk += s.charAt(marker);
|
||||
marker++;
|
||||
}
|
||||
} else {
|
||||
while (marker < length && !isDigit(s.charAt(marker))) {
|
||||
chunk += s.charAt(marker);
|
||||
marker++;
|
||||
}
|
||||
}
|
||||
return { chunk, newMarker: marker };
|
||||
};
|
||||
|
||||
const len1 = a.length;
|
||||
const len2 = b.length;
|
||||
let marker1 = 0;
|
||||
let marker2 = 0;
|
||||
|
||||
while (marker1 < len1 && marker2 < len2) {
|
||||
const { chunk: chunk1, newMarker: newMarker1 } = getChunk(
|
||||
a,
|
||||
len1,
|
||||
marker1,
|
||||
);
|
||||
marker1 = newMarker1;
|
||||
|
||||
const { chunk: chunk2, newMarker: newMarker2 } = getChunk(
|
||||
b,
|
||||
len2,
|
||||
marker2,
|
||||
);
|
||||
marker2 = newMarker2;
|
||||
|
||||
let result: number;
|
||||
if (isDigit(chunk1.charAt(0)) && isDigit(chunk2.charAt(0))) {
|
||||
const num1 = parseInt(chunk1, 10);
|
||||
const num2 = parseInt(chunk2, 10);
|
||||
result = num1 - num2;
|
||||
} else {
|
||||
result = chunk1.localeCompare(chunk2);
|
||||
}
|
||||
|
||||
if (result !== 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return len1 - len2;
|
||||
}, []);
|
||||
|
||||
// Custom file sorting logic for merge tool
|
||||
const sortFiles = useCallback(
|
||||
(sortType: "filename" | "dateModified", ascending: boolean = true) => {
|
||||
const sortedStubs = [...fileStubs].sort((stubA, stubB) => {
|
||||
let comparison = 0;
|
||||
switch (sortType) {
|
||||
case "filename":
|
||||
comparison = naturalCompare(stubA.name, stubB.name);
|
||||
break;
|
||||
case "dateModified":
|
||||
comparison = stubA.lastModified - stubB.lastModified;
|
||||
break;
|
||||
}
|
||||
return ascending ? comparison : -comparison;
|
||||
});
|
||||
|
||||
const selectedIds = sortedStubs.map((record) => record.id);
|
||||
const deselectedIds = fileIds.filter((id) => !selectedIds.includes(id));
|
||||
reorderFiles([...selectedIds, ...deselectedIds]);
|
||||
},
|
||||
[fileStubs, fileIds, reorderFiles, naturalCompare],
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
minFiles: 2,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Sort Files",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
content: (
|
||||
<MergeFileSorter
|
||||
onSortFiles={sortFiles}
|
||||
disabled={!base.hasFiles || base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: mergeTips,
|
||||
content: (
|
||||
<MergeSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("merge.submit", "Merge PDFs"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
disabledReason: isViewerMode ? "viewerMode" : undefined,
|
||||
},
|
||||
belowExecuteButton:
|
||||
isViewerMode && !base.hasResults ? (
|
||||
<Stack align="center" gap={6} mx="md" mt={4}>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{t(
|
||||
"merge.viewerModeHint",
|
||||
"Merge needs 2 or more files. Head to the file editor to select them.",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={() => navActions.setWorkbench("fileEditor")}
|
||||
>
|
||||
{t("merge.goToFileEditor", "Go to file editor")}
|
||||
</Button>
|
||||
</Stack>
|
||||
) : undefined,
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("merge.title", "Merge Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Merge as ToolComponent;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
|
||||
import OCRSettings from "@app/components/tools/ocr/OCRSettings";
|
||||
import AdvancedOCRSettings from "@app/components/tools/ocr/AdvancedOCRSettings";
|
||||
|
||||
import { useOCRParameters } from "@app/hooks/tools/ocr/useOCRParameters";
|
||||
import { useOCROperation } from "@app/hooks/tools/ocr/useOCROperation";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useOCRTips } from "@app/components/tooltips/useOCRTips";
|
||||
import { useAdvancedOCRTips } from "@app/components/tooltips/useAdvancedOCRTips";
|
||||
|
||||
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const ocrParams = useOCRParameters();
|
||||
const ocrOperation = useOCROperation();
|
||||
const ocrTips = useOCRTips();
|
||||
const advancedOCRTips = useAdvancedOCRTips();
|
||||
|
||||
// Step expansion state management
|
||||
const [expandedStep, setExpandedStep] = useState<
|
||||
"files" | "settings" | "advanced" | null
|
||||
>("files");
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled("ocr-pdf");
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults =
|
||||
ocrOperation.files.length > 0 || ocrOperation.downloadUrl !== null;
|
||||
const hasValidSettings = ocrParams.validateParameters();
|
||||
|
||||
useEffect(() => {
|
||||
ocrOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [ocrParams.parameters]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length > 0 && expandedStep === "files") {
|
||||
setExpandedStep("settings");
|
||||
}
|
||||
}, [selectedFiles.length, expandedStep]);
|
||||
|
||||
// Collapse all steps when results appear
|
||||
useEffect(() => {
|
||||
if (hasResults) {
|
||||
setExpandedStep(null);
|
||||
}
|
||||
}, [hasResults]);
|
||||
|
||||
const handleOCR = async () => {
|
||||
try {
|
||||
await ocrOperation.executeOperation(ocrParams.parameters, selectedFiles);
|
||||
if (ocrOperation.files && onComplete) {
|
||||
onComplete(ocrOperation.files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (onError) {
|
||||
onError(
|
||||
error instanceof Error ? error.message : "OCR operation failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem("previousMode", "ocr");
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
ocrOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
const handleUndo = async () => {
|
||||
await ocrOperation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
};
|
||||
|
||||
const settingsCollapsed = expandedStep !== "settings";
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("ocr.settings.title", "Settings"),
|
||||
isCollapsed: !hasFiles || settingsCollapsed,
|
||||
onCollapsedClick: hasResults
|
||||
? handleSettingsReset
|
||||
: () => {
|
||||
if (!hasFiles) return; // Only allow if files are selected
|
||||
setExpandedStep(expandedStep === "settings" ? null : "settings");
|
||||
},
|
||||
tooltip: ocrTips,
|
||||
content: (
|
||||
<OCRSettings
|
||||
parameters={ocrParams.parameters}
|
||||
onParameterChange={ocrParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Advanced",
|
||||
isCollapsed: expandedStep !== "advanced",
|
||||
onCollapsedClick: hasResults
|
||||
? handleSettingsReset
|
||||
: () => {
|
||||
if (!hasFiles) return; // Only allow if files are selected
|
||||
setExpandedStep(expandedStep === "advanced" ? null : "advanced");
|
||||
},
|
||||
tooltip: advancedOCRTips,
|
||||
content: (
|
||||
<AdvancedOCRSettings
|
||||
advancedOptions={ocrParams.parameters.additionalOptions}
|
||||
ocrRenderType={ocrParams.parameters.ocrRenderType}
|
||||
onParameterChange={ocrParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("ocr.operation.submit", "Process OCR and Review"),
|
||||
loadingText: t("loading"),
|
||||
onClick: handleOCR,
|
||||
isVisible: !hasResults,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: hasValidSettings,
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: ocrOperation,
|
||||
title: t("ocr.results.title", "OCR Results"),
|
||||
onFileClick: handleThumbnailClick,
|
||||
onUndo: handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
OCR.tool = () => useOCROperation;
|
||||
|
||||
export default OCR as ToolComponent;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings";
|
||||
import { useOverlayPdfsParameters } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsParameters";
|
||||
import { useOverlayPdfsOperation } from "@app/hooks/tools/overlayPdfs/useOverlayPdfsOperation";
|
||||
import { useOverlayPdfsTips } from "@app/components/tooltips/useOverlayPdfsTips";
|
||||
|
||||
const OverlayPdfs = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"overlay-pdfs",
|
||||
useOverlayPdfsParameters,
|
||||
useOverlayPdfsOperation,
|
||||
props,
|
||||
);
|
||||
const overlayTips = useOverlayPdfsTips();
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("overlay-pdfs.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: overlayTips,
|
||||
content: (
|
||||
<OverlayPdfsSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("overlay-pdfs.submit", "Overlay and Review"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("overlay-pdfs.results.title", "Overlay Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default OverlayPdfs as ToolComponent;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { usePageLayoutParameters } from "@app/hooks/tools/pageLayout/usePageLayoutParameters";
|
||||
import { usePageLayoutOperation } from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
|
||||
import PageLayoutSettings from "@app/components/tools/pageLayout/PageLayoutSettings";
|
||||
import PageLayoutAdvancedSettings from "@app/components/tools/pageLayout/PageLayoutAdvancedSettings";
|
||||
import PageLayoutMarginsBordersSettings from "@app/components/tools/pageLayout/PageLayoutMarginsBordersSettings";
|
||||
import { usePageLayoutTips } from "@app/components/tooltips/PageLayout/usePageLayoutTips";
|
||||
import { usePageLayoutAdvancedTips } from "@app/components/tooltips/PageLayout/usePageLayoutAdvancedTips";
|
||||
import { usePageLayoutMarginsBordersTips } from "@app/components/tooltips/PageLayout/usePageLayoutMarginsBordersTips";
|
||||
import PageLayoutPreview from "@app/components/tools/pageLayout/PageLayoutPreview";
|
||||
|
||||
enum PageLayoutStep {
|
||||
NONE = "none",
|
||||
LAYOUT = "layout",
|
||||
ADVANCED = "advanced",
|
||||
MARGINS_BORDERS = "marginsBorders",
|
||||
}
|
||||
|
||||
const PageLayout = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"pageLayout",
|
||||
usePageLayoutParameters,
|
||||
usePageLayoutOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const pageLayoutTips = usePageLayoutTips();
|
||||
const pageLayoutAdvancedTips = usePageLayoutAdvancedTips();
|
||||
const pageLayoutMarginsBordersTips = usePageLayoutMarginsBordersTips();
|
||||
|
||||
const accordion = useAccordionSteps<PageLayoutStep>({
|
||||
noneValue: PageLayoutStep.NONE,
|
||||
initialStep: PageLayoutStep.LAYOUT,
|
||||
stateConditions: {
|
||||
hasFiles: base.hasFiles,
|
||||
hasResults: base.hasResults,
|
||||
},
|
||||
afterResults: base.handleSettingsReset,
|
||||
});
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
preview: <PageLayoutPreview parameters={base.params.parameters} />,
|
||||
steps: [
|
||||
{
|
||||
title: "Layout settings",
|
||||
isCollapsed: accordion.getCollapsedState(PageLayoutStep.LAYOUT),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(PageLayoutStep.LAYOUT),
|
||||
tooltip: pageLayoutTips,
|
||||
content: (
|
||||
<PageLayoutSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Advanced settings",
|
||||
isCollapsed: accordion.getCollapsedState(PageLayoutStep.ADVANCED),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(PageLayoutStep.ADVANCED),
|
||||
tooltip: pageLayoutAdvancedTips,
|
||||
content: (
|
||||
<PageLayoutAdvancedSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Margins and borders",
|
||||
isCollapsed: accordion.getCollapsedState(
|
||||
PageLayoutStep.MARGINS_BORDERS,
|
||||
),
|
||||
onCollapsedClick: () =>
|
||||
accordion.handleStepToggle(PageLayoutStep.MARGINS_BORDERS),
|
||||
tooltip: pageLayoutMarginsBordersTips,
|
||||
content: (
|
||||
<PageLayoutMarginsBordersSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("pageLayout.submit", "Create Layout"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("pageLayout.title", "Multi Page Layout Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default PageLayout as ToolComponent;
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector";
|
||||
import {
|
||||
useRedactParameters,
|
||||
RedactMode,
|
||||
} from "@app/hooks/tools/redact/useRedactParameters";
|
||||
import { useRedactOperation } from "@app/hooks/tools/redact/useRedactOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import {
|
||||
useRedactModeTips,
|
||||
useRedactWordsTips,
|
||||
useRedactAdvancedTips,
|
||||
useRedactManualTips,
|
||||
} from "@app/components/tooltips/useRedactTips";
|
||||
import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings";
|
||||
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
|
||||
import ManualRedactionControls from "@app/components/tools/redact/ManualRedactionControls";
|
||||
import {
|
||||
useNavigationActions,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useRedaction } from "@app/contexts/RedactionContext";
|
||||
import { useFileState } from "@app/contexts/file/fileHooks";
|
||||
|
||||
const Redact = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State for managing step collapse status
|
||||
const [methodCollapsed, setMethodCollapsed] = useState(false);
|
||||
const [wordsCollapsed, setWordsCollapsed] = useState(false);
|
||||
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
|
||||
|
||||
// Navigation and redaction context
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { setRedactionConfig, setRedactionMode, redactionConfig } =
|
||||
useRedaction();
|
||||
const { workbench } = useNavigationState();
|
||||
const hasOpenedViewer = useRef(false);
|
||||
|
||||
const base = useBaseTool(
|
||||
"redact",
|
||||
useRedactParameters,
|
||||
useRedactOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
// Get total file count from context (any files in workbench, not just selected)
|
||||
const { state: fileState } = useFileState();
|
||||
const hasAnyFiles = fileState.files.ids.length > 0;
|
||||
|
||||
// Tooltips for each step
|
||||
const modeTips = useRedactModeTips();
|
||||
const wordsTips = useRedactWordsTips();
|
||||
const advancedTips = useRedactAdvancedTips();
|
||||
const manualTips = useRedactManualTips();
|
||||
|
||||
// Auto-set manual mode if we're in the viewer and redaction config is set to manual
|
||||
// This ensures when opening redact from viewer, it automatically selects manual mode
|
||||
useEffect(() => {
|
||||
if (
|
||||
workbench === "viewer" &&
|
||||
redactionConfig?.mode === "manual" &&
|
||||
base.params.parameters.mode !== "manual"
|
||||
) {
|
||||
// Set immediately when conditions are met
|
||||
base.params.updateParameter("mode", "manual");
|
||||
}
|
||||
}, [
|
||||
workbench,
|
||||
redactionConfig,
|
||||
base.params.parameters.mode,
|
||||
base.params.updateParameter,
|
||||
]);
|
||||
|
||||
// Handle mode change - navigate to viewer when manual mode is selected
|
||||
// Manual mode works with any files in workbench (not just selected files)
|
||||
const handleModeChange = (mode: RedactMode) => {
|
||||
base.params.updateParameter("mode", mode);
|
||||
|
||||
if (mode === "manual" && hasAnyFiles) {
|
||||
// Set redaction config and navigate to viewer
|
||||
setRedactionConfig(base.params.parameters);
|
||||
setRedactionMode(true);
|
||||
navActions.setWorkbench("viewer");
|
||||
hasOpenedViewer.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// When files are added and in manual mode, navigate to viewer
|
||||
// Uses hasAnyFiles since manual mode works with any files in workbench
|
||||
useEffect(() => {
|
||||
if (
|
||||
base.params.parameters.mode === "manual" &&
|
||||
hasAnyFiles &&
|
||||
!hasOpenedViewer.current
|
||||
) {
|
||||
setRedactionConfig(base.params.parameters);
|
||||
setRedactionMode(true);
|
||||
navActions.setWorkbench("viewer");
|
||||
hasOpenedViewer.current = true;
|
||||
}
|
||||
}, [
|
||||
hasAnyFiles,
|
||||
base.params.parameters,
|
||||
navActions,
|
||||
setRedactionConfig,
|
||||
setRedactionMode,
|
||||
]);
|
||||
|
||||
// Reset viewer flag when mode changes back to automatic
|
||||
useEffect(() => {
|
||||
if (base.params.parameters.mode === "automatic") {
|
||||
hasOpenedViewer.current = false;
|
||||
setRedactionMode(false);
|
||||
}
|
||||
}, [base.params.parameters.mode, setRedactionMode]);
|
||||
|
||||
const isExecuteDisabled = () => {
|
||||
if (base.params.parameters.mode === "manual") {
|
||||
return true; // Manual mode uses viewer, not execute button
|
||||
}
|
||||
return (
|
||||
!base.params.validateParameters() ||
|
||||
!base.hasFiles ||
|
||||
!base.endpointEnabled
|
||||
);
|
||||
};
|
||||
|
||||
// Compute actual collapsed state based on results and user state
|
||||
const getActualCollapsedState = (userCollapsed: boolean) => {
|
||||
return base.hasResults ? true : userCollapsed; // Force collapse when results are shown
|
||||
};
|
||||
|
||||
// Build conditional steps based on redaction mode
|
||||
const buildSteps = () => {
|
||||
// Method step is always expandable (even without files selected)
|
||||
// Only collapse on results or user preference
|
||||
const methodStepCollapsed = base.hasResults ? true : methodCollapsed;
|
||||
|
||||
const steps = [
|
||||
// Method selection step (always present and always expandable)
|
||||
{
|
||||
title: t("redact.modeSelector.title", "Redaction Method"),
|
||||
isCollapsed: methodStepCollapsed,
|
||||
onCollapsedClick: () =>
|
||||
base.settingsCollapsed
|
||||
? base.handleSettingsReset()
|
||||
: setMethodCollapsed(!methodCollapsed),
|
||||
tooltip: modeTips,
|
||||
content: (
|
||||
<RedactModeSelector
|
||||
mode={base.params.parameters.mode}
|
||||
onModeChange={handleModeChange}
|
||||
disabled={base.endpointLoading}
|
||||
hasAnyFiles={hasAnyFiles}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Add mode-specific steps
|
||||
if (base.params.parameters.mode === "automatic") {
|
||||
steps.push(
|
||||
{
|
||||
title: t("redact.auto.settings.title", "Redaction Settings"),
|
||||
isCollapsed: getActualCollapsedState(wordsCollapsed),
|
||||
onCollapsedClick: () =>
|
||||
base.settingsCollapsed
|
||||
? base.handleSettingsReset()
|
||||
: setWordsCollapsed(!wordsCollapsed),
|
||||
tooltip: wordsTips,
|
||||
content: (
|
||||
<WordsToRedactInput
|
||||
wordsToRedact={base.params.parameters.wordsToRedact}
|
||||
onWordsChange={(words) =>
|
||||
base.params.updateParameter("wordsToRedact", words)
|
||||
}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("redact.auto.settings.advancedTitle", "Advanced Settings"),
|
||||
isCollapsed: getActualCollapsedState(advancedCollapsed),
|
||||
onCollapsedClick: () =>
|
||||
base.settingsCollapsed
|
||||
? base.handleSettingsReset()
|
||||
: setAdvancedCollapsed(!advancedCollapsed),
|
||||
tooltip: advancedTips,
|
||||
content: (
|
||||
<RedactAdvancedSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
} else if (base.params.parameters.mode === "manual") {
|
||||
// Manual mode - show redaction controls
|
||||
// Uses hasAnyFiles since manual mode works with any files in workbench (viewer-powered)
|
||||
steps.push({
|
||||
title: t("redact.manual.controlsTitle", "Manual Redaction Controls"),
|
||||
isCollapsed: false,
|
||||
onCollapsedClick: () => {},
|
||||
tooltip: manualTips,
|
||||
content: <ManualRedactionControls disabled={!hasAnyFiles} />,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
// Hide execute button in manual mode (redactions applied via controls)
|
||||
const isManualMode = base.params.parameters.mode === "manual";
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: buildSteps(),
|
||||
executeButton: {
|
||||
text: t("redact.submit", "Redact"),
|
||||
isVisible: !base.hasResults && !isManualMode,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: isExecuteDisabled(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("redact.title", "Redaction Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Redact as ToolComponent;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import RemoveAnnotationsSettings from "@app/components/tools/removeAnnotations/RemoveAnnotationsSettings";
|
||||
import { useRemoveAnnotationsParameters } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters";
|
||||
import { useRemoveAnnotationsOperation } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useRemoveAnnotationsTips } from "@app/components/tooltips/useRemoveAnnotationsTips";
|
||||
|
||||
const RemoveAnnotations = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const removeAnnotationsTips = useRemoveAnnotationsTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"removeAnnotations",
|
||||
useRemoveAnnotationsParameters,
|
||||
useRemoveAnnotationsOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("removeAnnotations.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: removeAnnotationsTips,
|
||||
content: <RemoveAnnotationsSettings />,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("removeAnnotations.submit", "Remove Annotations"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading", "Processing..."),
|
||||
onClick: base.handleExecute,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removeAnnotations.title", "Annotations Removed"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default RemoveAnnotations as ToolComponent;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useRemoveBlanksParameters } from "@app/hooks/tools/removeBlanks/useRemoveBlanksParameters";
|
||||
import { useRemoveBlanksOperation } from "@app/hooks/tools/removeBlanks/useRemoveBlanksOperation";
|
||||
import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlanksSettings";
|
||||
import { useRemoveBlanksTips } from "@app/components/tooltips/useRemoveBlanksTips";
|
||||
|
||||
const RemoveBlanks = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const tooltipContent = useRemoveBlanksTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"remove-blanks",
|
||||
useRemoveBlanksParameters,
|
||||
useRemoveBlanksOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const settingsContent = (
|
||||
<RemoveBlanksSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
const handleSettingsClick = () => {
|
||||
if (base.hasResults) {
|
||||
base.handleSettingsReset();
|
||||
}
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("removeBlanks.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: handleSettingsClick,
|
||||
content: settingsContent,
|
||||
tooltip: tooltipContent,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("removeBlanks.submit", "Remove blank pages"),
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
isVisible: !base.hasResults,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removeBlanks.results.title", "Removed Blank Pages"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
RemoveBlanks.tool = () => useRemoveBlanksOperation;
|
||||
|
||||
export default RemoveBlanks as ToolComponent;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useRemoveCertificateSignParameters } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters";
|
||||
import { useRemoveCertificateSignOperation } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const RemoveCertificateSign = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"removeCertificateSign",
|
||||
useRemoveCertificateSignParameters,
|
||||
useRemoveCertificateSignOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
text: t("removeCertSign.submit", "Remove Signature"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removeCertSign.results.title", "Certificate Removal Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
RemoveCertificateSign.tool = () => useRemoveCertificateSignOperation;
|
||||
|
||||
export default RemoveCertificateSign as ToolComponent;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useRemoveImageParameters } from "@app/hooks/tools/removeImage/useRemoveImageParameters";
|
||||
import { useRemoveImageOperation } from "@app/hooks/tools/removeImage/useRemoveImageOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const RemoveImage = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"removeImage",
|
||||
useRemoveImageParameters,
|
||||
useRemoveImageOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
text: t("removeImage.submit", "Remove Images"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removeImage.results.title", "Remove Images Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
RemoveImage.tool = () => useRemoveImageOperation;
|
||||
|
||||
export default RemoveImage as ToolComponent;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useRemovePagesParameters } from "@app/hooks/tools/removePages/useRemovePagesParameters";
|
||||
import { useRemovePagesOperation } from "@app/hooks/tools/removePages/useRemovePagesOperation";
|
||||
import RemovePagesSettings from "@app/components/tools/removePages/RemovePagesSettings";
|
||||
import { useRemovePagesTips } from "@app/components/tooltips/useRemovePagesTips";
|
||||
|
||||
const RemovePages = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const tooltipContent = useRemovePagesTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"remove-pages",
|
||||
useRemovePagesParameters,
|
||||
useRemovePagesOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const settingsContent = (
|
||||
<RemovePagesSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("removePages.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
content: settingsContent,
|
||||
tooltip: tooltipContent,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("removePages.submit", "Remove Pages"),
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
isVisible: !base.hasResults,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removePages.results.title", "Pages Removed"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
RemovePages.tool = () => useRemovePagesOperation;
|
||||
|
||||
export default RemovePages as ToolComponent;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
|
||||
import { useRemovePasswordParameters } from "@app/hooks/tools/removePassword/useRemovePasswordParameters";
|
||||
import { useRemovePasswordOperation } from "@app/hooks/tools/removePassword/useRemovePasswordOperation";
|
||||
import { useRemovePasswordTips } from "@app/components/tooltips/useRemovePasswordTips";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const RemovePassword = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const removePasswordTips = useRemovePasswordTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"removePassword",
|
||||
useRemovePasswordParameters,
|
||||
useRemovePasswordOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("removePassword.password.stepTitle", "Remove Password"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.hasResults
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: removePasswordTips,
|
||||
content: (
|
||||
<RemovePasswordSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("removePassword.submit", "Remove Password"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("removePassword.results.title", "Decrypted PDFs"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
RemovePassword.tool = () => useRemovePasswordOperation;
|
||||
|
||||
export default RemovePassword as ToolComponent;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useEndpointEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import ReorganizePagesSettings from "@app/components/tools/reorganizePages/ReorganizePagesSettings";
|
||||
import { useReorganizePagesParameters } from "@app/hooks/tools/reorganizePages/useReorganizePagesParameters";
|
||||
import { useReorganizePagesOperation } from "@app/hooks/tools/reorganizePages/useReorganizePagesOperation";
|
||||
|
||||
const ReorganizePages = ({
|
||||
onPreviewFile,
|
||||
onComplete,
|
||||
onError,
|
||||
}: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const selectedFiles = useViewScopedFiles();
|
||||
|
||||
const params = useReorganizePagesParameters();
|
||||
const operation = useReorganizePagesOperation();
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } =
|
||||
useEndpointEnabled("rearrange-pages");
|
||||
|
||||
useEffect(() => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [params.parameters]);
|
||||
|
||||
const handleExecute = async () => {
|
||||
try {
|
||||
await operation.executeOperation(params.parameters, selectedFiles);
|
||||
if (operation.files && onComplete) {
|
||||
onComplete(operation.files);
|
||||
}
|
||||
} catch (error: any) {
|
||||
onError?.(
|
||||
error?.message ||
|
||||
t("reorganizePages.error.failed", "Failed to reorganize pages"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults =
|
||||
operation.files.length > 0 || operation.downloadUrl !== null;
|
||||
|
||||
enum Step {
|
||||
NONE = "none",
|
||||
SETTINGS = "settings",
|
||||
}
|
||||
|
||||
const accordion = useAccordionSteps<Step>({
|
||||
noneValue: Step.NONE,
|
||||
initialStep: Step.SETTINGS,
|
||||
stateConditions: {
|
||||
hasFiles,
|
||||
hasResults,
|
||||
},
|
||||
afterResults: () => {
|
||||
operation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: t("reorganizePages.settings.title", "Settings"),
|
||||
isCollapsed: accordion.getCollapsedState(Step.SETTINGS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(Step.SETTINGS),
|
||||
isVisible: true,
|
||||
content: (
|
||||
<ReorganizePagesSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps,
|
||||
executeButton: {
|
||||
text: t("reorganizePages.submit", "Reorganize Pages"),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: handleExecute,
|
||||
endpointEnabled: endpointEnabled,
|
||||
paramsValid: params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: operation,
|
||||
title: t("reorganizePages.results.title", "Pages Reorganized"),
|
||||
onFileClick: (file) => onPreviewFile?.(file),
|
||||
onUndo: async () => {
|
||||
await operation.undoOperation();
|
||||
onPreviewFile?.(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
(ReorganizePages as any).tool = () => useReorganizePagesOperation;
|
||||
|
||||
export default ReorganizePages as ToolComponent;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useRepairParameters } from "@app/hooks/tools/repair/useRepairParameters";
|
||||
import { useRepairOperation } from "@app/hooks/tools/repair/useRepairOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const Repair = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"repair",
|
||||
useRepairParameters,
|
||||
useRepairOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
text: t("repair.submit", "Repair PDF"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("repair.results.title", "Repair Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
Repair.tool = () => useRepairOperation;
|
||||
|
||||
export default Repair as ToolComponent;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import ReplaceColorSettings from "@app/components/tools/replaceColor/ReplaceColorSettings";
|
||||
import { useReplaceColorParameters } from "@app/hooks/tools/replaceColor/useReplaceColorParameters";
|
||||
import { useReplaceColorOperation } from "@app/hooks/tools/replaceColor/useReplaceColorOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useReplaceColorTips } from "@app/components/tooltips/useReplaceColorTips";
|
||||
|
||||
const ReplaceColor = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const replaceColorTips = useReplaceColorTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"replaceColor",
|
||||
useReplaceColorParameters,
|
||||
useReplaceColorOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("replaceColor.labels.settings", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: replaceColorTips,
|
||||
content: (
|
||||
<ReplaceColorSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("replace-color.submit", "Replace"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("replace-color.title", "Replace-Invert-Color"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ReplaceColor as ToolComponent;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import RotateSettings from "@app/components/tools/rotate/RotateSettings";
|
||||
import { useRotateParameters } from "@app/hooks/tools/rotate/useRotateParameters";
|
||||
import { useRotateOperation } from "@app/hooks/tools/rotate/useRotateOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useRotateTips } from "@app/components/tooltips/useRotateTips";
|
||||
|
||||
const Rotate = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const rotateTips = useRotateTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"rotate",
|
||||
useRotateParameters,
|
||||
useRotateOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: rotateTips,
|
||||
content: (
|
||||
<RotateSettings
|
||||
parameters={base.params}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("rotate.submit", "Apply Rotation"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("rotate.title", "Rotation Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Rotate as ToolComponent;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import SanitizeSettings from "@app/components/tools/sanitize/SanitizeSettings";
|
||||
import { useSanitizeParameters } from "@app/hooks/tools/sanitize/useSanitizeParameters";
|
||||
import { useSanitizeOperation } from "@app/hooks/tools/sanitize/useSanitizeOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const Sanitize = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"sanitize",
|
||||
useSanitizeParameters,
|
||||
useSanitizeOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("sanitize.steps.settings", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
content: (
|
||||
<SanitizeSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("sanitize.submit", "Sanitize PDF"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("sanitize.sanitizationResults", "Sanitization Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
Sanitize.tool = () => useSanitizeOperation;
|
||||
|
||||
export default Sanitize as ToolComponent;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import ScannerImageSplitSettings from "@app/components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import { useScannerImageSplitParameters } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
|
||||
import { useScannerImageSplitOperation } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useScannerImageSplitTips } from "@app/components/tooltips/useScannerImageSplitTips";
|
||||
|
||||
const ScannerImageSplit = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const scannerImageSplitTips = useScannerImageSplitTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
"scannerImageSplit",
|
||||
useScannerImageSplitParameters,
|
||||
useScannerImageSplitOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: scannerImageSplitTips,
|
||||
content: (
|
||||
<ScannerImageSplitSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("scannerImageSplit.submit", "Extract Image Scans"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("scannerImageSplit.title", "Extracted Images"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ScannerImageSplit as ToolComponent;
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import type { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import {
|
||||
useShowJSParameters,
|
||||
defaultParameters,
|
||||
} from "@app/hooks/tools/showJS/useShowJSParameters";
|
||||
import {
|
||||
useShowJSOperation,
|
||||
type ShowJSOperationHook,
|
||||
} from "@app/hooks/tools/showJS/useShowJSOperation";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
useNavigationActions,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import ShowJSView from "@app/components/tools/showJS/ShowJSView";
|
||||
import { useFileSelection } from "@app/contexts/file/fileHooks";
|
||||
|
||||
const ShowJS = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const VIEW_ID = "showJSView";
|
||||
const WORKBENCH_ID = "custom:showJS" as const;
|
||||
const viewIcon = useMemo(() => <CodeRoundedIcon fontSize="small" />, []);
|
||||
|
||||
const base = useBaseTool(
|
||||
"showJS",
|
||||
useShowJSParameters,
|
||||
useShowJSOperation,
|
||||
props,
|
||||
{ minFiles: 1 },
|
||||
);
|
||||
const operation = base.operation as ShowJSOperationHook;
|
||||
const hasResults = Boolean(operation.scriptText);
|
||||
const { clearSelections } = useFileSelection();
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: VIEW_ID,
|
||||
workbenchId: WORKBENCH_ID,
|
||||
label: t("showJS.view.title", "JavaScript"),
|
||||
icon: viewIcon,
|
||||
component: ({ data }) => <ShowJSView data={data} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(VIEW_ID);
|
||||
unregisterCustomWorkbenchView(VIEW_ID);
|
||||
};
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
registerCustomWorkbenchView,
|
||||
t,
|
||||
unregisterCustomWorkbenchView,
|
||||
viewIcon,
|
||||
]);
|
||||
|
||||
const lastShownRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (operation.scriptText) {
|
||||
setCustomWorkbenchViewData(VIEW_ID, {
|
||||
scriptText: operation.scriptText,
|
||||
downloadUrl: operation.downloadUrl,
|
||||
downloadFilename: operation.downloadFilename,
|
||||
});
|
||||
const marker = operation.scriptText.length;
|
||||
const isNew =
|
||||
lastShownRef.current == null || marker !== lastShownRef.current;
|
||||
if (isNew) {
|
||||
lastShownRef.current = marker;
|
||||
if (
|
||||
navigationState.selectedTool === "showJS" &&
|
||||
navigationState.workbench !== WORKBENCH_ID
|
||||
) {
|
||||
navigationActions.setWorkbench(WORKBENCH_ID);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearCustomWorkbenchViewData(VIEW_ID);
|
||||
lastShownRef.current = null;
|
||||
}
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
navigationActions,
|
||||
navigationState.selectedTool,
|
||||
navigationState.workbench,
|
||||
operation.scriptText,
|
||||
setCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((base.selectedFiles?.length ?? 0) === 0) {
|
||||
try {
|
||||
base.operation.resetResults();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
clearCustomWorkbenchViewData(VIEW_ID);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
if (navigationState.workbench === WORKBENCH_ID) {
|
||||
try {
|
||||
navigationActions.setWorkbench("fileEditor");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
lastShownRef.current = null;
|
||||
}
|
||||
}, [
|
||||
base.selectedFiles?.length,
|
||||
base.operation,
|
||||
clearCustomWorkbenchViewData,
|
||||
navigationActions,
|
||||
navigationState.workbench,
|
||||
]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: false,
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
text: hasResults
|
||||
? t("back", "Back")
|
||||
: t("showJS.submit", "Extract JavaScript"),
|
||||
loadingText: t("loading", "Loading..."),
|
||||
onClick: hasResults
|
||||
? async () => {
|
||||
// Clear results and deselect files so user can pick another file
|
||||
try {
|
||||
await base.operation.resetResults();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
clearSelections();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
// Close the custom JS view and send user back to file manager to pick another file
|
||||
try {
|
||||
clearCustomWorkbenchViewData(VIEW_ID);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
navigationActions.setWorkbench("fileEditor");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
: base.handleExecute,
|
||||
disabled: hasResults
|
||||
? false
|
||||
: !base.hasFiles ||
|
||||
(base.selectedFiles?.length ?? 0) !== 1 ||
|
||||
base.operation.isLoading ||
|
||||
base.endpointLoading ||
|
||||
base.endpointEnabled === false,
|
||||
isVisible: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: hasResults,
|
||||
operation: base.operation,
|
||||
title: t("showJS.results", "Result"),
|
||||
onUndo: undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const ShowJSTool = ShowJS as ToolComponent;
|
||||
ShowJSTool.tool = () => useShowJSOperation;
|
||||
ShowJSTool.getDefaultParameters = () => ({ ...defaultParameters });
|
||||
|
||||
export default ShowJSTool;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createStampTool } from "@app/tools/stamp/createStampTool";
|
||||
|
||||
const Sign = createStampTool({
|
||||
toolId: "sign",
|
||||
translationScope: "sign",
|
||||
allowedSignatureSources: ["canvas", "image", "text", "saved"],
|
||||
defaultSignatureSource: "canvas",
|
||||
defaultSignatureType: "canvas",
|
||||
enableApplyAction: true,
|
||||
});
|
||||
|
||||
export default Sign;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useSingleLargePageParameters } from "@app/hooks/tools/singleLargePage/useSingleLargePageParameters";
|
||||
import { useSingleLargePageOperation } from "@app/hooks/tools/singleLargePage/useSingleLargePageOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const SingleLargePage = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"singleLargePage",
|
||||
useSingleLargePageParameters,
|
||||
useSingleLargePageOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
text: t("pdfToSinglePage.submit", "Convert To Single Page"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("pdfToSinglePage.results.title", "Single Page Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
SingleLargePage.tool = () => useSingleLargePageOperation;
|
||||
|
||||
export default SingleLargePage as ToolComponent;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import CardSelector from "@app/components/shared/CardSelector";
|
||||
import SplitSettings from "@app/components/tools/split/SplitSettings";
|
||||
import { useSplitParameters } from "@app/hooks/tools/split/useSplitParameters";
|
||||
import { useSplitOperation } from "@app/hooks/tools/split/useSplitOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { useSplitMethodTips } from "@app/components/tooltips/useSplitMethodTips";
|
||||
import { useSplitSettingsTips } from "@app/components/tooltips/useSplitSettingsTips";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import {
|
||||
type SplitMethod,
|
||||
METHOD_OPTIONS,
|
||||
type MethodOption,
|
||||
ENDPOINTS,
|
||||
} from "@app/constants/splitConstants";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
|
||||
const Split = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"split",
|
||||
useSplitParameters,
|
||||
useSplitOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
// Check which split endpoints are available
|
||||
const allSplitEndpoints = useMemo(() => Object.values(ENDPOINTS), []);
|
||||
const { endpointStatus } = useMultipleEndpointsEnabled(allSplitEndpoints);
|
||||
|
||||
// Filter METHOD_OPTIONS to only show methods with enabled endpoints
|
||||
const availableMethodOptions = useMemo(() => {
|
||||
return METHOD_OPTIONS.filter((option) => {
|
||||
const endpoint = ENDPOINTS[option.value];
|
||||
// If endpoint status is not loaded yet, show all options (optimistic)
|
||||
// If endpoint is explicitly disabled (false), hide the option
|
||||
return endpointStatus[endpoint] !== false;
|
||||
});
|
||||
}, [endpointStatus]);
|
||||
|
||||
const methodTips = useSplitMethodTips();
|
||||
const settingsTips = useSplitSettingsTips(base.params.parameters.method);
|
||||
|
||||
// Get the method name for the settings step title
|
||||
const getSettingsTitle = () => {
|
||||
if (!base.params.parameters.method)
|
||||
return t("split.steps.settings", "Settings");
|
||||
|
||||
const methodOption = METHOD_OPTIONS.find(
|
||||
(option) => option.value === base.params.parameters.method,
|
||||
);
|
||||
if (!methodOption) return t("split.steps.settings", "Settings");
|
||||
|
||||
const prefix = t(methodOption.prefixKey, "Split by");
|
||||
const name = t(methodOption.nameKey, "Method Name");
|
||||
return `${prefix} ${name}`;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("split.steps.chooseMethod", "Choose Method"),
|
||||
isCollapsed: !!base.params.parameters.method, // Collapse when method is selected
|
||||
onCollapsedClick: () => base.params.updateParameter("method", null),
|
||||
tooltip: methodTips,
|
||||
content: (
|
||||
<CardSelector<SplitMethod, MethodOption>
|
||||
options={availableMethodOptions}
|
||||
onSelect={(method) => base.params.updateParameter("method", method)}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: getSettingsTitle(),
|
||||
isCollapsed: !base.params.parameters.method, // Collapsed until method selected
|
||||
onCollapsedClick: base.hasResults
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
tooltip: settingsTips || undefined,
|
||||
content: (
|
||||
<SplitSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("split.submit", "Split PDF"),
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
isVisible: !base.hasResults,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("split.resultsTitle", "Split Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default Split as ToolComponent;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { BaseToolProps } from "@app/types/tool";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
const SwaggerUI: React.FC<BaseToolProps> = () => {
|
||||
useEffect(() => {
|
||||
// Redirect to Swagger UI
|
||||
window.open(withBasePath("/swagger-ui/5.21.0/index.html"), "_blank");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "2rem" }}>
|
||||
<p>Opening Swagger UI in a new tab...</p>
|
||||
<p>
|
||||
If it didn't open automatically,{" "}
|
||||
<a
|
||||
href={withBasePath("/swagger-ui/5.21.0/index.html")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
click here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwaggerUI;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import TimestampPdfSettings from "@app/components/tools/timestampPdf/TimestampPdfSettings";
|
||||
import { useTimestampPdfParameters } from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters";
|
||||
import { useTimestampPdfOperation } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const TimestampPdf = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"timestampPdf",
|
||||
useTimestampPdfParameters,
|
||||
useTimestampPdfOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("timestampPdf.steps.settings", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
content: (
|
||||
<TimestampPdfSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("timestampPdf.submit", "Apply Timestamp"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled:
|
||||
!base.params.validateParameters() ||
|
||||
!base.hasFiles ||
|
||||
!base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("timestampPdf.results", "Timestamp Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
TimestampPdf.tool = () => useTimestampPdfOperation;
|
||||
|
||||
export default TimestampPdf as ToolComponent;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useUnlockPdfFormsParameters } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters";
|
||||
import { useUnlockPdfFormsOperation } from "@app/hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
|
||||
const UnlockPdfForms = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const base = useBaseTool(
|
||||
"unlockPdfForms",
|
||||
useUnlockPdfFormsParameters,
|
||||
useUnlockPdfFormsOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasFiles || base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
executeButton: {
|
||||
text: t("unlockPDFForms.submit", "Unlock Forms"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("unlockPDFForms.results.title", "Unlocked Forms Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Static method to get the operation hook for automation
|
||||
UnlockPdfForms.tool = () => useUnlockPdfFormsOperation;
|
||||
|
||||
export default UnlockPdfForms as ToolComponent;
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import {
|
||||
useValidateSignatureParameters,
|
||||
defaultParameters,
|
||||
} from "@app/hooks/tools/validateSignature/useValidateSignatureParameters";
|
||||
import ValidateSignatureSettings from "@app/components/tools/validateSignature/ValidateSignatureSettings";
|
||||
import ValidateSignatureResults from "@app/components/tools/validateSignature/ValidateSignatureResults";
|
||||
import {
|
||||
useValidateSignatureOperation,
|
||||
ValidateSignatureOperationHook,
|
||||
} from "@app/hooks/tools/validateSignature/useValidateSignatureOperation";
|
||||
import ValidateSignatureReportView from "@app/components/tools/validateSignature/ValidateSignatureReportView";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import {
|
||||
useNavigationActions,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import type { SignatureValidationReportData } from "@app/types/validateSignature";
|
||||
|
||||
const ValidateSignature = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const REPORT_VIEW_ID = "validateSignatureReport";
|
||||
const REPORT_WORKBENCH_ID = "custom:validateSignatureReport" as const;
|
||||
const reportIcon = useMemo(() => <PictureAsPdfIcon fontSize="small" />, []);
|
||||
|
||||
const base = useBaseTool(
|
||||
"validateSignature",
|
||||
useValidateSignatureParameters,
|
||||
useValidateSignatureOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const operation = base.operation as ValidateSignatureOperationHook;
|
||||
const hasResults = operation.results.length > 0;
|
||||
const showResultsStep =
|
||||
hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: REPORT_VIEW_ID,
|
||||
workbenchId: REPORT_WORKBENCH_ID,
|
||||
label: t("validateSignature.report.shortTitle", "Signature Report"),
|
||||
icon: reportIcon,
|
||||
component: ValidateSignatureReportView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(REPORT_VIEW_ID);
|
||||
};
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
registerCustomWorkbenchView,
|
||||
reportIcon,
|
||||
t,
|
||||
unregisterCustomWorkbenchView,
|
||||
]);
|
||||
|
||||
const reportData = useMemo<SignatureValidationReportData | null>(() => {
|
||||
if (operation.results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now();
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
entries: operation.results,
|
||||
};
|
||||
}, [operation.results]);
|
||||
|
||||
// Track last time we auto-navigated to the report so we don't override
|
||||
// the user's manual tab change. Only navigate when a new report is generated.
|
||||
const lastReportGeneratedAtRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportData) {
|
||||
setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData);
|
||||
|
||||
const generatedAt = reportData.generatedAt ?? null;
|
||||
const isNewReport =
|
||||
generatedAt && generatedAt !== lastReportGeneratedAtRef.current;
|
||||
|
||||
if (isNewReport) {
|
||||
lastReportGeneratedAtRef.current = generatedAt;
|
||||
if (
|
||||
navigationState.selectedTool === "validateSignature" &&
|
||||
navigationState.workbench !== REPORT_WORKBENCH_ID
|
||||
) {
|
||||
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
lastReportGeneratedAtRef.current = null;
|
||||
}
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
navigationActions,
|
||||
navigationState.selectedTool,
|
||||
navigationState.workbench,
|
||||
reportData,
|
||||
setCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t("validateSignature.settings.title", "Validation Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed
|
||||
? base.handleSettingsReset
|
||||
: undefined,
|
||||
content: (
|
||||
<ValidateSignatureSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.operation.isLoading || base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("validateSignature.results", "Validation Results"),
|
||||
isVisible: showResultsStep,
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<ValidateSignatureResults
|
||||
operation={operation}
|
||||
results={operation.results}
|
||||
isLoading={base.operation.isLoading}
|
||||
errorMessage={base.operation.errorMessage}
|
||||
reportAvailable={Boolean(reportData)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("validateSignature.submit", "Validate Signatures"),
|
||||
loadingText: t("loading", "Loading..."),
|
||||
onClick: base.handleExecute,
|
||||
endpointEnabled: base.endpointEnabled,
|
||||
paramsValid: base.params.validateParameters(),
|
||||
isVisible: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: t("validateSignature.results", "Validation Results"),
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const ValidateSignatureTool = ValidateSignature as ToolComponent;
|
||||
ValidateSignatureTool.tool = () => useValidateSignatureOperation;
|
||||
ValidateSignatureTool.getDefaultParameters = () => ({ ...defaultParameters });
|
||||
|
||||
export default ValidateSignatureTool;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
AnnotationAPI,
|
||||
AnnotationToolId,
|
||||
} from "@app/components/viewer/viewerTypes";
|
||||
|
||||
interface UseAnnotationSelectionParams {
|
||||
annotationApiRef: React.RefObject<AnnotationAPI | null>;
|
||||
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
|
||||
activeToolRef: React.MutableRefObject<AnnotationToolId>;
|
||||
setActiveTool: (toolId: AnnotationToolId) => void;
|
||||
setSelectedTextDraft: (text: string) => void;
|
||||
setSelectedFontSize: (size: number) => void;
|
||||
setInkWidth: (value: number) => void;
|
||||
setFreehandHighlighterWidth?: (value: number) => void;
|
||||
setShapeThickness: (value: number) => void;
|
||||
setTextColor: (value: string) => void;
|
||||
setTextBackgroundColor: (value: string) => void;
|
||||
setNoteBackgroundColor: (value: string) => void;
|
||||
setInkColor: (value: string) => void;
|
||||
setHighlightColor: (value: string) => void;
|
||||
setHighlightOpacity: (value: number) => void;
|
||||
setUnderlineColor: (value: string) => void;
|
||||
setUnderlineOpacity: (value: number) => void;
|
||||
setStrikeoutColor: (value: string) => void;
|
||||
setStrikeoutOpacity: (value: number) => void;
|
||||
setSquigglyColor: (value: string) => void;
|
||||
setSquigglyOpacity: (value: number) => void;
|
||||
setShapeStrokeColor: (value: string) => void;
|
||||
setShapeFillColor: (value: string) => void;
|
||||
setShapeOpacity: (value: number) => void;
|
||||
setShapeStrokeOpacity: (value: number) => void;
|
||||
setShapeFillOpacity: (value: number) => void;
|
||||
setTextAlignment: (value: "left" | "center" | "right") => void;
|
||||
}
|
||||
|
||||
const MARKUP_TOOL_IDS = [
|
||||
"highlight",
|
||||
"underline",
|
||||
"strikeout",
|
||||
"squiggly",
|
||||
] as const;
|
||||
const DRAWING_TOOL_IDS = ["ink", "inkHighlighter"] as const;
|
||||
const STAY_ACTIVE_TOOL_IDS = [...MARKUP_TOOL_IDS, ...DRAWING_TOOL_IDS] as const;
|
||||
|
||||
const isTextMarkupAnnotation = (annotation: any): boolean => {
|
||||
const toolId =
|
||||
annotation?.customData?.annotationToolId ||
|
||||
annotation?.customData?.toolId ||
|
||||
annotation?.object?.customData?.annotationToolId ||
|
||||
annotation?.object?.customData?.toolId;
|
||||
if (toolId && MARKUP_TOOL_IDS.includes(toolId)) return true;
|
||||
|
||||
const type = annotation?.type ?? annotation?.object?.type;
|
||||
if (typeof type === "number" && [9, 10, 11, 12].includes(type)) return true;
|
||||
|
||||
const subtype = annotation?.subtype ?? annotation?.object?.subtype;
|
||||
if (typeof subtype === "string") {
|
||||
const lower = subtype.toLowerCase();
|
||||
if (MARKUP_TOOL_IDS.some((t) => lower.includes(t))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const shouldStayOnPlacementTool = (
|
||||
annotation: any,
|
||||
derivedTool?: string | null | undefined,
|
||||
): boolean => {
|
||||
// Text markup tools (highlight, underline, strikeout, squiggly) and drawing tools (ink, inkHighlighter) stay active
|
||||
// All other tools switch to select mode after placement
|
||||
|
||||
const toolId =
|
||||
derivedTool ||
|
||||
annotation?.customData?.annotationToolId ||
|
||||
annotation?.customData?.toolId ||
|
||||
annotation?.object?.customData?.annotationToolId ||
|
||||
annotation?.object?.customData?.toolId;
|
||||
|
||||
// Check if it's a tool that should stay active
|
||||
if (toolId && STAY_ACTIVE_TOOL_IDS.includes(toolId as any)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a markup annotation by type/subtype
|
||||
if (isTextMarkupAnnotation(annotation)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// All other tools (text, note, shapes, lines, stamps) switch to select
|
||||
return false;
|
||||
};
|
||||
|
||||
export function useAnnotationSelection({
|
||||
annotationApiRef,
|
||||
deriveToolFromAnnotation,
|
||||
activeToolRef,
|
||||
setActiveTool,
|
||||
setSelectedTextDraft,
|
||||
setSelectedFontSize,
|
||||
setInkWidth,
|
||||
setShapeThickness,
|
||||
setTextColor,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
setFreehandHighlighterWidth,
|
||||
}: UseAnnotationSelectionParams) {
|
||||
const [selectedAnn, setSelectedAnn] = useState<any | null>(null);
|
||||
const [selectedAnnId, setSelectedAnnId] = useState<string | null>(null);
|
||||
const selectedAnnIdRef = useRef<string | null>(null);
|
||||
|
||||
const applySelectionFromAnnotation = useCallback(
|
||||
(ann: any | null) => {
|
||||
const annObject = ann?.object ?? ann ?? null;
|
||||
const annId = annObject?.id ?? null;
|
||||
const type = annObject?.type;
|
||||
const derivedTool = annObject
|
||||
? deriveToolFromAnnotation(annObject)
|
||||
: undefined;
|
||||
selectedAnnIdRef.current = annId;
|
||||
setSelectedAnnId(annId);
|
||||
// Normalize selected annotation to always expose .object for edit panels
|
||||
const normalizedSelection = ann?.object
|
||||
? ann
|
||||
: annObject
|
||||
? { object: annObject }
|
||||
: null;
|
||||
setSelectedAnn(normalizedSelection);
|
||||
|
||||
if (annObject?.contents !== undefined) {
|
||||
setSelectedTextDraft(annObject.contents ?? "");
|
||||
}
|
||||
if (annObject?.fontSize !== undefined) {
|
||||
setSelectedFontSize(annObject.fontSize ?? 14);
|
||||
}
|
||||
if (annObject?.textAlign !== undefined) {
|
||||
const align = annObject.textAlign;
|
||||
if (typeof align === "string") {
|
||||
const normalized =
|
||||
align === "center"
|
||||
? "center"
|
||||
: align === "right"
|
||||
? "right"
|
||||
: "left";
|
||||
setTextAlignment(normalized);
|
||||
} else if (typeof align === "number") {
|
||||
const normalized =
|
||||
align === 1 ? "center" : align === 2 ? "right" : "left";
|
||||
setTextAlignment(normalized);
|
||||
}
|
||||
}
|
||||
if (type === 3) {
|
||||
const background =
|
||||
(annObject?.backgroundColor as string | undefined) ||
|
||||
(annObject?.fillColor as string | undefined) ||
|
||||
undefined;
|
||||
const textColor =
|
||||
(annObject?.textColor as string | undefined) ||
|
||||
(annObject?.color as string | undefined);
|
||||
if (textColor) {
|
||||
setTextColor(textColor);
|
||||
}
|
||||
if (derivedTool === "note") {
|
||||
setNoteBackgroundColor(background || "");
|
||||
} else {
|
||||
setTextBackgroundColor(background || "");
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 15) {
|
||||
const width =
|
||||
annObject?.strokeWidth ??
|
||||
annObject?.borderWidth ??
|
||||
annObject?.lineWidth ??
|
||||
annObject?.thickness;
|
||||
if (derivedTool === "inkHighlighter") {
|
||||
if (annObject?.color) setHighlightColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined) {
|
||||
setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
}
|
||||
if (width !== undefined && setFreehandHighlighterWidth) {
|
||||
setFreehandHighlighterWidth(width);
|
||||
}
|
||||
} else {
|
||||
if (width !== undefined) setInkWidth(width ?? 2);
|
||||
if (annObject?.color) {
|
||||
setInkColor(annObject.color);
|
||||
}
|
||||
}
|
||||
} else if (type >= 4 && type <= 8) {
|
||||
const width =
|
||||
annObject?.strokeWidth ??
|
||||
annObject?.borderWidth ??
|
||||
annObject?.lineWidth;
|
||||
if (width !== undefined) {
|
||||
setShapeThickness(width ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 9) {
|
||||
if (annObject?.color) setHighlightColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined)
|
||||
setHighlightOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 10) {
|
||||
if (annObject?.color) setUnderlineColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined)
|
||||
setUnderlineOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 12) {
|
||||
if (annObject?.color) setStrikeoutColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined)
|
||||
setStrikeoutOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
} else if (type === 11) {
|
||||
if (annObject?.color) setSquigglyColor(annObject.color);
|
||||
if (annObject?.opacity !== undefined)
|
||||
setSquigglyOpacity(Math.round((annObject.opacity ?? 1) * 100));
|
||||
}
|
||||
|
||||
if ([4, 5, 6, 7, 8].includes(type)) {
|
||||
const stroke =
|
||||
(annObject?.strokeColor as string | undefined) ??
|
||||
(annObject?.color as string | undefined);
|
||||
if (stroke) setShapeStrokeColor(stroke);
|
||||
if ([5, 6, 7].includes(type)) {
|
||||
const fill =
|
||||
(annObject?.color as string | undefined) ??
|
||||
(annObject?.fillColor as string | undefined);
|
||||
if (fill) setShapeFillColor(fill);
|
||||
}
|
||||
const opacity =
|
||||
annObject?.opacity !== undefined
|
||||
? Math.round((annObject.opacity ?? 1) * 100)
|
||||
: undefined;
|
||||
const strokeOpacityValue =
|
||||
annObject?.strokeOpacity !== undefined
|
||||
? Math.round((annObject.strokeOpacity ?? 1) * 100)
|
||||
: undefined;
|
||||
const fillOpacityValue =
|
||||
annObject?.fillOpacity !== undefined
|
||||
? Math.round((annObject.fillOpacity ?? 1) * 100)
|
||||
: undefined;
|
||||
if (opacity !== undefined) {
|
||||
setShapeOpacity(opacity);
|
||||
setShapeStrokeOpacity(strokeOpacityValue ?? opacity);
|
||||
setShapeFillOpacity(fillOpacityValue ?? opacity);
|
||||
} else {
|
||||
if (strokeOpacityValue !== undefined)
|
||||
setShapeStrokeOpacity(strokeOpacityValue);
|
||||
if (fillOpacityValue !== undefined)
|
||||
setShapeFillOpacity(fillOpacityValue);
|
||||
}
|
||||
}
|
||||
|
||||
const matchingTool = derivedTool;
|
||||
const stayOnPlacement = shouldStayOnPlacementTool(
|
||||
annObject,
|
||||
matchingTool,
|
||||
);
|
||||
if (
|
||||
matchingTool &&
|
||||
activeToolRef.current !== "select" &&
|
||||
!stayOnPlacement
|
||||
) {
|
||||
activeToolRef.current = "select";
|
||||
setActiveTool("select");
|
||||
// Immediately enable select tool to avoid re-entering placement after creation.
|
||||
annotationApiRef.current?.activateAnnotationTool?.("select");
|
||||
} else if (activeToolRef.current === "select") {
|
||||
// Keep the viewer in Select mode so clicking existing annotations does not re-enable placement.
|
||||
annotationApiRef.current?.activateAnnotationTool?.("select");
|
||||
}
|
||||
},
|
||||
[
|
||||
activeToolRef,
|
||||
annotationApiRef,
|
||||
deriveToolFromAnnotation,
|
||||
setActiveTool,
|
||||
setInkWidth,
|
||||
setNoteBackgroundColor,
|
||||
setSelectedFontSize,
|
||||
setSelectedTextDraft,
|
||||
setShapeThickness,
|
||||
setTextBackgroundColor,
|
||||
setTextColor,
|
||||
setInkColor,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setTextAlignment,
|
||||
setFreehandHighlighterWidth,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const api = annotationApiRef.current as any;
|
||||
if (!api) return;
|
||||
|
||||
const checkSelection = () => {
|
||||
let ann: any = null;
|
||||
if (typeof api.getSelectedAnnotation === "function") {
|
||||
try {
|
||||
ann = api.getSelectedAnnotation();
|
||||
} catch (error) {
|
||||
// Some builds of the annotation plugin can throw when reading
|
||||
// internal selection state (e.g., accessing `selectedUid` on
|
||||
// an undefined object). Treat this as "no current selection"
|
||||
// instead of crashing the annotations tool.
|
||||
// Only log unexpected errors - "No active document" is a common expected state during init
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (!errorMessage.includes("No active document")) {
|
||||
console.error(
|
||||
"[useAnnotationSelection] getSelectedAnnotation failed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
ann = null;
|
||||
}
|
||||
}
|
||||
const currentId = ann?.object?.id ?? ann?.id ?? null;
|
||||
if (currentId !== selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(ann ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
if (typeof api.onAnnotationEvent === "function") {
|
||||
const handler = (event: any) => {
|
||||
const ann = event?.annotation ?? event?.selectedAnnotation ?? null;
|
||||
const eventType = event?.type;
|
||||
switch (eventType) {
|
||||
case "create":
|
||||
case "add":
|
||||
case "added":
|
||||
case "created":
|
||||
case "annotationCreated":
|
||||
case "annotationAdded":
|
||||
case "complete": {
|
||||
const eventAnn = ann ?? api.getSelectedAnnotation?.();
|
||||
applySelectionFromAnnotation(eventAnn);
|
||||
const currentTool = activeToolRef.current;
|
||||
const tool =
|
||||
deriveToolFromAnnotation(
|
||||
(eventAnn as any)?.object ??
|
||||
eventAnn ??
|
||||
api.getSelectedAnnotation?.(),
|
||||
) || currentTool;
|
||||
const stayOnPlacement = shouldStayOnPlacementTool(eventAnn, tool);
|
||||
if (activeToolRef.current !== "select" && !stayOnPlacement) {
|
||||
activeToolRef.current = "select";
|
||||
setActiveTool("select");
|
||||
annotationApiRef.current?.activateAnnotationTool?.("select");
|
||||
}
|
||||
// Re-read selection after the viewer updates to ensure we have the full annotation object for the edit panel.
|
||||
setTimeout(() => {
|
||||
const selected = api.getSelectedAnnotation?.();
|
||||
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
|
||||
const derivedAfter =
|
||||
deriveToolFromAnnotation(
|
||||
(selected as any)?.object ?? selected ?? eventAnn ?? null,
|
||||
) || activeToolRef.current;
|
||||
const stayOnPlacementAfter = shouldStayOnPlacementTool(
|
||||
selected ?? eventAnn ?? null,
|
||||
derivedAfter,
|
||||
);
|
||||
if (activeToolRef.current !== "select" && !stayOnPlacementAfter) {
|
||||
activeToolRef.current = "select";
|
||||
setActiveTool("select");
|
||||
annotationApiRef.current?.activateAnnotationTool?.("select");
|
||||
}
|
||||
}, 50);
|
||||
break;
|
||||
}
|
||||
case "select":
|
||||
case "selected":
|
||||
case "annotationSelected":
|
||||
case "annotationClicked":
|
||||
case "annotationTapped":
|
||||
applySelectionFromAnnotation(ann ?? api.getSelectedAnnotation?.());
|
||||
break;
|
||||
case "deselect":
|
||||
case "clearSelection":
|
||||
applySelectionFromAnnotation(null);
|
||||
break;
|
||||
case "delete":
|
||||
case "remove":
|
||||
if (ann?.id && ann.id === selectedAnnIdRef.current) {
|
||||
applySelectionFromAnnotation(null);
|
||||
}
|
||||
break;
|
||||
case "update":
|
||||
case "change":
|
||||
if (selectedAnnIdRef.current) {
|
||||
const current = api.getSelectedAnnotation?.();
|
||||
if (current) {
|
||||
applySelectionFromAnnotation(current);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = api.onAnnotationEvent(handler);
|
||||
interval = setInterval(checkSelection, 450);
|
||||
return () => {
|
||||
if (typeof unsubscribe === "function") {
|
||||
unsubscribe();
|
||||
}
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}
|
||||
|
||||
interval = setInterval(checkSelection, 350);
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [annotationApiRef, applySelectionFromAnnotation]);
|
||||
|
||||
return {
|
||||
selectedAnn,
|
||||
selectedAnnId,
|
||||
selectedAnnIdRef,
|
||||
setSelectedAnn,
|
||||
setSelectedAnnId,
|
||||
applySelectionFromAnnotation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { AnnotationToolId } from "@app/components/viewer/viewerTypes";
|
||||
|
||||
type Size = { width: number; height: number };
|
||||
|
||||
export type BuildToolOptionsExtras = {
|
||||
includeMetadata?: boolean;
|
||||
stampImageData?: string;
|
||||
stampImageSize?: Size | null;
|
||||
};
|
||||
|
||||
interface StyleState {
|
||||
inkColor: string;
|
||||
inkWidth: number;
|
||||
highlightColor: string;
|
||||
highlightOpacity: number;
|
||||
freehandHighlighterWidth: number;
|
||||
underlineColor: string;
|
||||
underlineOpacity: number;
|
||||
strikeoutColor: string;
|
||||
strikeoutOpacity: number;
|
||||
squigglyColor: string;
|
||||
squigglyOpacity: number;
|
||||
textColor: string;
|
||||
textSize: number;
|
||||
textAlignment: "left" | "center" | "right";
|
||||
textBackgroundColor: string;
|
||||
noteBackgroundColor: string;
|
||||
shapeStrokeColor: string;
|
||||
shapeFillColor: string;
|
||||
shapeOpacity: number;
|
||||
shapeStrokeOpacity: number;
|
||||
shapeFillOpacity: number;
|
||||
shapeThickness: number;
|
||||
}
|
||||
|
||||
interface StyleActions {
|
||||
setInkColor: (value: string) => void;
|
||||
setInkWidth: (value: number) => void;
|
||||
setHighlightColor: (value: string) => void;
|
||||
setHighlightOpacity: (value: number) => void;
|
||||
setFreehandHighlighterWidth: (value: number) => void;
|
||||
setUnderlineColor: (value: string) => void;
|
||||
setUnderlineOpacity: (value: number) => void;
|
||||
setStrikeoutColor: (value: string) => void;
|
||||
setStrikeoutOpacity: (value: number) => void;
|
||||
setSquigglyColor: (value: string) => void;
|
||||
setSquigglyOpacity: (value: number) => void;
|
||||
setTextColor: (value: string) => void;
|
||||
setTextSize: (value: number) => void;
|
||||
setTextAlignment: (value: "left" | "center" | "right") => void;
|
||||
setTextBackgroundColor: (value: string) => void;
|
||||
setNoteBackgroundColor: (value: string) => void;
|
||||
setShapeStrokeColor: (value: string) => void;
|
||||
setShapeFillColor: (value: string) => void;
|
||||
setShapeOpacity: (value: number) => void;
|
||||
setShapeStrokeOpacity: (value: number) => void;
|
||||
setShapeFillOpacity: (value: number) => void;
|
||||
setShapeThickness: (value: number) => void;
|
||||
}
|
||||
|
||||
export type BuildToolOptionsFn = (
|
||||
toolId: AnnotationToolId,
|
||||
extras?: BuildToolOptionsExtras,
|
||||
) => Record<string, unknown>;
|
||||
|
||||
export interface AnnotationStyleStateReturn {
|
||||
styleState: StyleState;
|
||||
styleActions: StyleActions;
|
||||
buildToolOptions: BuildToolOptionsFn;
|
||||
getActiveColor: (target: string | null) => string;
|
||||
}
|
||||
|
||||
export const useAnnotationStyleState = (
|
||||
cssToPdfSize?: (size: Size) => Size,
|
||||
): AnnotationStyleStateReturn => {
|
||||
const [inkColor, setInkColor] = useState("#1f2933");
|
||||
const [inkWidth, setInkWidth] = useState(2);
|
||||
const [highlightColor, setHighlightColor] = useState("#ffd54f");
|
||||
const [highlightOpacity, setHighlightOpacity] = useState(60);
|
||||
const [freehandHighlighterWidth, setFreehandHighlighterWidth] = useState(6);
|
||||
const [underlineColor, setUnderlineColor] = useState("#ffb300");
|
||||
const [underlineOpacity, setUnderlineOpacity] = useState(100);
|
||||
const [strikeoutColor, setStrikeoutColor] = useState("#e53935");
|
||||
const [strikeoutOpacity, setStrikeoutOpacity] = useState(100);
|
||||
const [squigglyColor, setSquigglyColor] = useState("#00acc1");
|
||||
const [squigglyOpacity, setSquigglyOpacity] = useState(100);
|
||||
const [textColor, setTextColor] = useState("#111111");
|
||||
const [textSize, setTextSize] = useState(14);
|
||||
const [textAlignment, setTextAlignment] = useState<
|
||||
"left" | "center" | "right"
|
||||
>("left");
|
||||
const [textBackgroundColor, setTextBackgroundColor] = useState<string>("");
|
||||
const [noteBackgroundColor, setNoteBackgroundColor] = useState("#ffd54f");
|
||||
const [shapeStrokeColor, setShapeStrokeColor] = useState("#cf5b5b");
|
||||
const [shapeFillColor, setShapeFillColor] = useState("#0000ff");
|
||||
const [shapeOpacity, setShapeOpacity] = useState(50);
|
||||
const [shapeStrokeOpacity, setShapeStrokeOpacity] = useState(50);
|
||||
const [shapeFillOpacity, setShapeFillOpacity] = useState(50);
|
||||
const [shapeThickness, setShapeThickness] = useState(2);
|
||||
|
||||
const buildToolOptions = useCallback<BuildToolOptionsFn>(
|
||||
(toolId, extras) => {
|
||||
const includeMetadata = extras?.includeMetadata ?? true;
|
||||
const metadata = includeMetadata
|
||||
? {
|
||||
customData: {
|
||||
toolId,
|
||||
annotationToolId: toolId,
|
||||
source: "annotate",
|
||||
author: "User",
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
switch (toolId) {
|
||||
case "ink":
|
||||
return { color: inkColor, thickness: inkWidth, ...metadata };
|
||||
case "inkHighlighter":
|
||||
return {
|
||||
color: highlightColor,
|
||||
opacity: highlightOpacity / 100,
|
||||
thickness: freehandHighlighterWidth,
|
||||
...metadata,
|
||||
};
|
||||
case "highlight":
|
||||
return {
|
||||
color: highlightColor,
|
||||
opacity: highlightOpacity / 100,
|
||||
...metadata,
|
||||
};
|
||||
case "underline":
|
||||
return {
|
||||
color: underlineColor,
|
||||
opacity: underlineOpacity / 100,
|
||||
...metadata,
|
||||
};
|
||||
case "strikeout":
|
||||
return {
|
||||
color: strikeoutColor,
|
||||
opacity: strikeoutOpacity / 100,
|
||||
...metadata,
|
||||
};
|
||||
case "squiggly":
|
||||
return {
|
||||
color: squigglyColor,
|
||||
opacity: squigglyOpacity / 100,
|
||||
...metadata,
|
||||
};
|
||||
case "text": {
|
||||
const textAlignNumber =
|
||||
textAlignment === "left" ? 0 : textAlignment === "center" ? 1 : 2;
|
||||
return {
|
||||
color: textColor,
|
||||
textColor: textColor,
|
||||
fontSize: textSize,
|
||||
textAlign: textAlignNumber,
|
||||
...(textBackgroundColor ? { fillColor: textBackgroundColor } : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
case "note": {
|
||||
const noteFillColor = noteBackgroundColor || "transparent";
|
||||
return {
|
||||
color: textColor,
|
||||
fillColor: noteFillColor,
|
||||
opacity: 1,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
case "square":
|
||||
case "circle":
|
||||
case "polygon":
|
||||
return {
|
||||
color: shapeFillColor,
|
||||
strokeColor: shapeStrokeColor,
|
||||
opacity: shapeOpacity / 100,
|
||||
strokeOpacity: shapeStrokeOpacity / 100,
|
||||
fillOpacity: shapeFillOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
...metadata,
|
||||
};
|
||||
case "line":
|
||||
case "polyline":
|
||||
case "lineArrow":
|
||||
return {
|
||||
color: shapeStrokeColor,
|
||||
strokeColor: shapeStrokeColor,
|
||||
opacity: shapeStrokeOpacity / 100,
|
||||
borderWidth: shapeThickness,
|
||||
...metadata,
|
||||
};
|
||||
case "stamp": {
|
||||
const pdfSize =
|
||||
extras?.stampImageSize && cssToPdfSize
|
||||
? cssToPdfSize(extras.stampImageSize)
|
||||
: undefined;
|
||||
return {
|
||||
imageSrc: extras?.stampImageData,
|
||||
...(pdfSize ? { imageSize: pdfSize } : {}),
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...metadata };
|
||||
}
|
||||
},
|
||||
[
|
||||
cssToPdfSize,
|
||||
freehandHighlighterWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
inkColor,
|
||||
inkWidth,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeFillOpacity,
|
||||
shapeOpacity,
|
||||
shapeStrokeColor,
|
||||
shapeStrokeOpacity,
|
||||
shapeThickness,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
textSize,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
],
|
||||
);
|
||||
|
||||
const getActiveColor = useCallback(
|
||||
(target: string | null) => {
|
||||
if (target === "ink") return inkColor;
|
||||
if (target === "highlight" || target === "inkHighlighter")
|
||||
return highlightColor;
|
||||
if (target === "underline") return underlineColor;
|
||||
if (target === "strikeout") return strikeoutColor;
|
||||
if (target === "squiggly") return squigglyColor;
|
||||
if (target === "shapeStroke") return shapeStrokeColor;
|
||||
if (target === "shapeFill") return shapeFillColor;
|
||||
if (target === "textBackground") return textBackgroundColor || "#ffffff";
|
||||
if (target === "noteBackground") return noteBackgroundColor || "#ffffff";
|
||||
return textColor;
|
||||
},
|
||||
[
|
||||
highlightColor,
|
||||
inkColor,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeStrokeColor,
|
||||
squigglyColor,
|
||||
strikeoutColor,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
underlineColor,
|
||||
],
|
||||
);
|
||||
|
||||
const styleState: StyleState = useMemo(
|
||||
() => ({
|
||||
inkColor,
|
||||
inkWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
freehandHighlighterWidth,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
textColor,
|
||||
textSize,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
noteBackgroundColor,
|
||||
shapeStrokeColor,
|
||||
shapeFillColor,
|
||||
shapeOpacity,
|
||||
shapeStrokeOpacity,
|
||||
shapeFillOpacity,
|
||||
shapeThickness,
|
||||
}),
|
||||
[
|
||||
freehandHighlighterWidth,
|
||||
highlightColor,
|
||||
highlightOpacity,
|
||||
inkColor,
|
||||
inkWidth,
|
||||
noteBackgroundColor,
|
||||
shapeFillColor,
|
||||
shapeFillOpacity,
|
||||
shapeOpacity,
|
||||
shapeStrokeColor,
|
||||
shapeStrokeOpacity,
|
||||
shapeThickness,
|
||||
squigglyColor,
|
||||
squigglyOpacity,
|
||||
strikeoutColor,
|
||||
strikeoutOpacity,
|
||||
textAlignment,
|
||||
textBackgroundColor,
|
||||
textColor,
|
||||
textSize,
|
||||
underlineColor,
|
||||
underlineOpacity,
|
||||
],
|
||||
);
|
||||
|
||||
const styleActions: StyleActions = {
|
||||
setInkColor,
|
||||
setInkWidth,
|
||||
setHighlightColor,
|
||||
setHighlightOpacity,
|
||||
setFreehandHighlighterWidth,
|
||||
setUnderlineColor,
|
||||
setUnderlineOpacity,
|
||||
setStrikeoutColor,
|
||||
setStrikeoutOpacity,
|
||||
setSquigglyColor,
|
||||
setSquigglyOpacity,
|
||||
setTextColor,
|
||||
setTextSize,
|
||||
setTextAlignment,
|
||||
setTextBackgroundColor,
|
||||
setNoteBackgroundColor,
|
||||
setShapeStrokeColor,
|
||||
setShapeFillColor,
|
||||
setShapeOpacity,
|
||||
setShapeStrokeOpacity,
|
||||
setShapeFillOpacity,
|
||||
setShapeThickness,
|
||||
};
|
||||
|
||||
return {
|
||||
styleState,
|
||||
styleActions,
|
||||
buildToolOptions,
|
||||
getActiveColor,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* ButtonAppearanceOverlay — Renders PDF push-button widget appearances as
|
||||
* canvas bitmaps on top of a PDF page.
|
||||
*
|
||||
* This is a visual-only layer (pointerEvents: none). Click handling is done
|
||||
* separately by FormFieldOverlay's transparent hit-target divs.
|
||||
*
|
||||
* Uses the same EPDF_RenderAnnotBitmap / FPDF_FFLDraw pipeline as
|
||||
* SignatureFieldOverlay to produce the button's native PDF appearance.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState, memo } from "react";
|
||||
import {
|
||||
renderButtonFieldAppearances,
|
||||
type SignatureFieldAppearance,
|
||||
} from "@app/services/pdfiumService";
|
||||
|
||||
interface ButtonAppearanceOverlayProps {
|
||||
pageIndex: number;
|
||||
pdfSource: File | Blob | null;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
let _cachedSource: File | Blob | null = null;
|
||||
let _cachePromise: Promise<SignatureFieldAppearance[]> | null = null;
|
||||
|
||||
async function resolveButtonAppearances(
|
||||
source: File | Blob,
|
||||
): Promise<SignatureFieldAppearance[]> {
|
||||
if (source === _cachedSource && _cachePromise) return _cachePromise;
|
||||
_cachedSource = source;
|
||||
_cachePromise = source
|
||||
.arrayBuffer()
|
||||
.then((buf) => renderButtonFieldAppearances(buf));
|
||||
return _cachePromise;
|
||||
}
|
||||
function ButtonBitmapCanvas({
|
||||
imageData,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
}: {
|
||||
imageData: ImageData;
|
||||
cssWidth: number;
|
||||
cssHeight: number;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) ctx.putImageData(imageData, 0, 0);
|
||||
}, [imageData]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: cssWidth, height: cssHeight, display: "block" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function ButtonAppearanceOverlayInner({
|
||||
pageIndex,
|
||||
pdfSource,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: ButtonAppearanceOverlayProps) {
|
||||
const [appearances, setAppearances] = useState<SignatureFieldAppearance[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfSource) {
|
||||
setAppearances([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
resolveButtonAppearances(pdfSource)
|
||||
.then((res) => {
|
||||
if (!cancelled) setAppearances(res);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAppearances([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pdfSource]);
|
||||
|
||||
const pageAppearances = useMemo(
|
||||
() =>
|
||||
appearances.filter(
|
||||
(a) => a.pageIndex === pageIndex && a.imageData !== null,
|
||||
),
|
||||
[appearances, pageIndex],
|
||||
);
|
||||
|
||||
if (pageAppearances.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 4,
|
||||
}}
|
||||
data-button-appearance-page={pageIndex}
|
||||
>
|
||||
{pageAppearances.map((btn, idx) => {
|
||||
const sx =
|
||||
btn.sourcePageWidth > 0 ? pageWidth / btn.sourcePageWidth : 1;
|
||||
const sy =
|
||||
btn.sourcePageHeight > 0 ? pageHeight / btn.sourcePageHeight : 1;
|
||||
const left = btn.x * sx;
|
||||
const top = btn.y * sy;
|
||||
const width = btn.width * sx;
|
||||
const height = btn.height * sy;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`btn-appearance-${btn.fieldName}-${idx}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<ButtonBitmapCanvas
|
||||
imageData={btn.imageData!}
|
||||
cssWidth={width}
|
||||
cssHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ButtonAppearanceOverlay = memo(ButtonAppearanceOverlayInner);
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* FieldInput: Shared, self-subscribing form field input widget.
|
||||
*
|
||||
* Used by both FormFill (left panel) and FormFieldSidebar (right panel).
|
||||
* Each instance subscribes to its own field value via useFieldValue(),
|
||||
* so only the active widget re-renders when its value changes.
|
||||
*/
|
||||
import React, { useCallback, memo } from "react";
|
||||
import {
|
||||
TextInput,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
Radio,
|
||||
Select,
|
||||
MultiSelect,
|
||||
Stack,
|
||||
} from "@mantine/core";
|
||||
import { useFieldValue } from "@app/tools/formFill/FormFillContext";
|
||||
import type { FormField } from "@app/tools/formFill/types";
|
||||
|
||||
function FieldInputInner({
|
||||
field,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
field: FormField;
|
||||
value: string;
|
||||
onValueChange: (fieldName: string, value: string) => void;
|
||||
}) {
|
||||
const onChange = useCallback(
|
||||
(v: string) => onValueChange(field.name, v),
|
||||
[onValueChange, field.name],
|
||||
);
|
||||
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
if (field.multiline) {
|
||||
return (
|
||||
<Textarea
|
||||
size="xs"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={field.tooltip || `Enter ${field.label}`}
|
||||
disabled={field.readOnly}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
styles={{ input: { fontSize: "0.8125rem" } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
placeholder={field.tooltip || `Enter ${field.label}`}
|
||||
disabled={field.readOnly}
|
||||
styles={{ input: { fontSize: "0.8125rem" } }}
|
||||
/>
|
||||
);
|
||||
|
||||
case "checkbox": {
|
||||
const isChecked = !!value && value !== "Off";
|
||||
const onValue = (field.widgets && field.widgets[0]?.exportValue) || "Yes";
|
||||
return (
|
||||
<Checkbox
|
||||
size="xs"
|
||||
checked={isChecked}
|
||||
onChange={(e) => onChange(e.currentTarget.checked ? onValue : "Off")}
|
||||
label={field.label}
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "combobox": {
|
||||
const comboData = (field.options || []).map((opt, idx) => ({
|
||||
value: opt,
|
||||
label: (field.displayOptions && field.displayOptions[idx]) || opt,
|
||||
}));
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
data={comboData}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v || "")}
|
||||
placeholder={`Select ${field.label}`}
|
||||
clearable
|
||||
searchable
|
||||
disabled={field.readOnly}
|
||||
aria-label={field.label || field.name}
|
||||
aria-required={field.required}
|
||||
styles={{ input: { fontSize: "0.8125rem" } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "listbox": {
|
||||
const listData = (field.options || []).map((opt, idx) => ({
|
||||
value: opt,
|
||||
label: (field.displayOptions && field.displayOptions[idx]) || opt,
|
||||
}));
|
||||
if (field.multiSelect) {
|
||||
const selectedValues = value ? value.split(",").filter(Boolean) : [];
|
||||
return (
|
||||
<MultiSelect
|
||||
size="xs"
|
||||
data={listData}
|
||||
value={selectedValues}
|
||||
onChange={(vals) => onChange(vals.join(","))}
|
||||
placeholder={`Select ${field.label}`}
|
||||
searchable
|
||||
disabled={field.readOnly}
|
||||
aria-label={field.label || field.name}
|
||||
aria-required={field.required}
|
||||
styles={{ input: { fontSize: "0.8125rem" } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
data={listData}
|
||||
value={value || null}
|
||||
onChange={(v) => onChange(v || "")}
|
||||
placeholder={`Select ${field.label}`}
|
||||
clearable
|
||||
searchable
|
||||
disabled={field.readOnly}
|
||||
aria-label={field.label || field.name}
|
||||
aria-required={field.required}
|
||||
styles={{ input: { fontSize: "0.8125rem" } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case "radio": {
|
||||
const radioOptions: { value: string; label: string }[] = [];
|
||||
if (field.widgets && field.widgets.length > 0) {
|
||||
for (let i = 0; i < field.widgets.length; i++) {
|
||||
const w = field.widgets[i];
|
||||
// Use widget index as value; display option label, export value, or index
|
||||
const label =
|
||||
(field.options && field.options[i]) || w.exportValue || String(i);
|
||||
radioOptions.push({ value: String(i), label });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Radio.Group
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-label={field.label || field.name}
|
||||
aria-required={field.required}
|
||||
>
|
||||
<Stack gap={4} mt={4}>
|
||||
{radioOptions.map((opt) => (
|
||||
<Radio
|
||||
key={opt.value}
|
||||
size="xs"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
disabled={field.readOnly}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Radio.Group>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
disabled={field.readOnly}
|
||||
aria-label={field.label || field.name}
|
||||
aria-required={field.required}
|
||||
styles={{ input: { fontSize: "0.8125rem" } }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const FieldInputBase = memo(FieldInputInner);
|
||||
|
||||
/**
|
||||
* Self-subscribing FieldInput — reads its own value via useFieldValue.
|
||||
* Only re-renders when this specific field's value changes.
|
||||
*/
|
||||
export function FieldInput({
|
||||
field,
|
||||
onValueChange,
|
||||
}: {
|
||||
field: FormField;
|
||||
onValueChange: (fieldName: string, value: string) => void;
|
||||
}) {
|
||||
const value = useFieldValue(field.name);
|
||||
return (
|
||||
<FieldInputBase field={field} value={value} onValueChange={onValueChange} />
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldInput;
|
||||
@@ -0,0 +1,790 @@
|
||||
/**
|
||||
* FormFieldOverlay — Renders interactive HTML form widgets on top of a PDF page.
|
||||
*
|
||||
* This layer is placed inside the renderPage callback of the EmbedPDF Scroller,
|
||||
* similar to how AnnotationLayer, RedactionLayer, and LinkLayer work.
|
||||
*
|
||||
* It reads the form field coordinates (in un-rotated CSS space, top-left origin)
|
||||
* and scales them using the document scale from EmbedPDF.
|
||||
*
|
||||
* Each widget renders an appropriate HTML input (text, checkbox, dropdown, etc.)
|
||||
* that synchronises bidirectionally with FormFillContext values.
|
||||
*
|
||||
* Coordinate handling:
|
||||
* Both providers (PdfLibFormProvider and PdfBoxFormProvider) output widget
|
||||
* coordinates in un-rotated PDF space (y-flipped to CSS upper-left origin).
|
||||
* The <Rotate> component (which wraps this overlay along with page tiles)
|
||||
* handles visual rotation via CSS transforms — same as TilingLayer,
|
||||
* AnnotationLayer, and LinkLayer.
|
||||
*/
|
||||
import React, { useCallback, useMemo, memo } from "react";
|
||||
import { useDocumentState } from "@embedpdf/core/react";
|
||||
import {
|
||||
useFormFill,
|
||||
useFieldValue,
|
||||
} from "@app/tools/formFill/FormFillContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import type {
|
||||
FormField,
|
||||
WidgetCoordinates,
|
||||
ButtonAction,
|
||||
} from "@app/tools/formFill/types";
|
||||
|
||||
/**
|
||||
* Execute PDF JavaScript in a minimally sandboxed context.
|
||||
*
|
||||
* Implements a heuristic security check by statically rejecting scripts containing
|
||||
* common browser globals (`window`, `document`, `fetch`), reflection APIs,
|
||||
* or execution sinks (`eval`, `Function`).
|
||||
*
|
||||
* Valid scripts run in strict mode with dangerous globals explicitly masked
|
||||
* to `undefined`, allowing safe Acrobat APIs like `this.print()` or `app.alert()`.
|
||||
*/
|
||||
function executePdfJs(
|
||||
js: string,
|
||||
handlers: {
|
||||
print: () => void;
|
||||
save: () => void;
|
||||
submitForm: (url: string) => void;
|
||||
resetForm: () => void;
|
||||
},
|
||||
): void {
|
||||
// 1. Static sanitization: Reject scripts with potentially harmful or unneeded keywords.
|
||||
// This blocks most elementary exploits and prevents prototype tampering.
|
||||
const forbidden = [
|
||||
"window",
|
||||
"document",
|
||||
"fetch",
|
||||
"xmlhttprequest",
|
||||
"websocket",
|
||||
"worker",
|
||||
"eval",
|
||||
"settimeout",
|
||||
"setinterval",
|
||||
"function",
|
||||
"constructor",
|
||||
"__proto__",
|
||||
"prototype",
|
||||
"globalthis",
|
||||
"import",
|
||||
"require",
|
||||
];
|
||||
|
||||
const lowerJs = js.toLowerCase();
|
||||
for (const word of forbidden) {
|
||||
if (lowerJs.includes(word)) {
|
||||
console.warn(
|
||||
`[PDF JS] Execution blocked: Script contains suspicious keyword "${word}".`,
|
||||
"Script:",
|
||||
js,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Mock Acrobat API
|
||||
const doOpenUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (["http:", "https:", "mailto:"].includes(u.protocol)) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch {
|
||||
/* invalid URL — ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const app = {
|
||||
print: (_params?: unknown) => handlers.print(),
|
||||
alert: (msg: unknown) => {
|
||||
console.debug("[PDF JS] alert:", msg);
|
||||
},
|
||||
beep: () => {},
|
||||
response: () => null,
|
||||
execMenuItem: (item: string) => {
|
||||
switch (item) {
|
||||
case "Print":
|
||||
handlers.print();
|
||||
break;
|
||||
case "Save":
|
||||
handlers.save();
|
||||
break;
|
||||
case "Close":
|
||||
break; // no-op in browser context
|
||||
default:
|
||||
console.debug("[PDF JS] execMenuItem: unhandled item:", item);
|
||||
}
|
||||
},
|
||||
// Prevent prototype walking
|
||||
__proto__: null,
|
||||
};
|
||||
|
||||
const doc = {
|
||||
print: (_params?: unknown) => handlers.print(),
|
||||
save: (_params?: unknown) => handlers.save(),
|
||||
saveAs: (_params?: unknown) => handlers.save(),
|
||||
submitForm: (urlOrParams: unknown) => {
|
||||
const url =
|
||||
typeof urlOrParams === "string"
|
||||
? urlOrParams
|
||||
: (((urlOrParams as Record<string, unknown>)?.cURL as string) ?? "");
|
||||
if (url) doOpenUrl(url);
|
||||
else handlers.submitForm(url);
|
||||
},
|
||||
resetForm: (_fields?: unknown) => handlers.resetForm(),
|
||||
getField: (_name: string) => null,
|
||||
getAnnot: () => null,
|
||||
getURL: (url: string) => doOpenUrl(url),
|
||||
numPages: 1,
|
||||
dirty: false,
|
||||
};
|
||||
|
||||
// Stub event object — used by field calculation/validation scripts
|
||||
const event = {
|
||||
value: "",
|
||||
changeEx: "",
|
||||
change: "",
|
||||
rc: true,
|
||||
willCommit: false,
|
||||
target: null as null,
|
||||
};
|
||||
|
||||
try {
|
||||
// Pass doc, app, event as both `this` AND named parameters so scripts that
|
||||
// reference them as free variables (not just via `this`) work correctly.
|
||||
const fn = new Function("app", "doc", "event", js);
|
||||
fn.call(doc, app, doc, event);
|
||||
} catch (err) {
|
||||
// Swallow errors from missing PDF APIs; log in debug mode for tracing
|
||||
console.debug(
|
||||
"[PDF JS] Script execution error (expected for unsupported APIs):",
|
||||
err,
|
||||
"\nScript:",
|
||||
js.slice(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface WidgetInputProps {
|
||||
field: FormField;
|
||||
widget: WidgetCoordinates;
|
||||
isActive: boolean;
|
||||
error?: string;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
onFocus: (fieldName: string) => void;
|
||||
onChange: (fieldName: string, value: string) => void;
|
||||
onButtonClick: (field: FormField, action?: ButtonAction | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WidgetInput subscribes to its own field value via useSyncExternalStore,
|
||||
* so it only re-renders when its specific value changes — not when ANY
|
||||
* form value in the entire document changes.
|
||||
*/
|
||||
function WidgetInputInner({
|
||||
field,
|
||||
widget,
|
||||
isActive,
|
||||
error,
|
||||
scaleX,
|
||||
scaleY,
|
||||
onFocus,
|
||||
onChange,
|
||||
onButtonClick,
|
||||
}: WidgetInputProps) {
|
||||
// Per-field value subscription — only this widget re-renders when its value changes
|
||||
const value = useFieldValue(field.name);
|
||||
|
||||
// Coordinates are in visual CSS space (top-left origin).
|
||||
// Multiply by per-axis scale to get rendered pixel coordinates.
|
||||
const left = widget.x * scaleX;
|
||||
const top = widget.y * scaleY;
|
||||
const width = widget.width * scaleX;
|
||||
const height = widget.height * scaleY;
|
||||
|
||||
const borderColor = error
|
||||
? "#f44336"
|
||||
: isActive
|
||||
? "#2196F3"
|
||||
: "rgba(33, 150, 243, 0.4)";
|
||||
const bgColor = error
|
||||
? "#FFEBEE" // Red 50 (Opaque)
|
||||
: isActive
|
||||
? "#E3F2FD"
|
||||
: "#FFFFFF"; // Blue 50 (Opaque) : White (Opaque)
|
||||
|
||||
const commonStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
zIndex: 10,
|
||||
boxSizing: "border-box",
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderRadius: 1,
|
||||
background: isActive ? bgColor : "transparent",
|
||||
transition: "border-color 0.15s, background 0.15s, box-shadow 0.15s",
|
||||
boxShadow:
|
||||
isActive && field.type !== "radio" && field.type !== "checkbox"
|
||||
? `0 0 0 2px ${error ? "rgba(244, 67, 54, 0.25)" : "rgba(33, 150, 243, 0.25)"}`
|
||||
: "none",
|
||||
cursor: field.readOnly ? "default" : "text",
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: field.multiline ? "stretch" : "center",
|
||||
};
|
||||
|
||||
const stopPropagation = (e: React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
// Also stop immediate propagation to native listeners to block non-React subscribers
|
||||
if (e.nativeEvent) {
|
||||
e.nativeEvent.stopImmediatePropagation?.();
|
||||
}
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
style: commonStyle,
|
||||
onPointerDown: stopPropagation,
|
||||
onPointerUp: stopPropagation,
|
||||
onMouseDown: stopPropagation,
|
||||
onMouseUp: stopPropagation,
|
||||
onClick: stopPropagation,
|
||||
onDoubleClick: stopPropagation,
|
||||
onKeyDown: stopPropagation,
|
||||
onKeyUp: stopPropagation,
|
||||
onKeyPress: stopPropagation,
|
||||
onDragStart: stopPropagation,
|
||||
onSelect: stopPropagation,
|
||||
onContextMenu: stopPropagation,
|
||||
};
|
||||
|
||||
const captureStopProps = {
|
||||
onPointerDownCapture: stopPropagation,
|
||||
onPointerUpCapture: stopPropagation,
|
||||
onMouseDownCapture: stopPropagation,
|
||||
onMouseUpCapture: stopPropagation,
|
||||
onClickCapture: stopPropagation,
|
||||
onKeyDownCapture: stopPropagation,
|
||||
onKeyUpCapture: stopPropagation,
|
||||
onKeyPressCapture: stopPropagation,
|
||||
};
|
||||
|
||||
const fontSize = widget.fontSize
|
||||
? widget.fontSize * scaleY
|
||||
: field.multiline
|
||||
? Math.max(6, Math.min(height * 0.6, 14))
|
||||
: Math.max(6, height * 0.65);
|
||||
|
||||
const inputBaseStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
paddingLeft: `${Math.max(2, 4 * scaleX)}px`,
|
||||
paddingRight: `${Math.max(2, 4 * scaleX)}px`,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: "Helvetica, Arial, sans-serif",
|
||||
color: "#000",
|
||||
boxSizing: "border-box",
|
||||
lineHeight: "normal",
|
||||
};
|
||||
|
||||
const handleFocus = () => onFocus(field.name);
|
||||
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div {...commonProps} title={error || field.tooltip || field.label}>
|
||||
{field.multiline ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(field.name, e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
disabled={field.readOnly}
|
||||
placeholder={field.label}
|
||||
style={{
|
||||
...inputBaseStyle,
|
||||
resize: "none",
|
||||
overflow: "auto",
|
||||
paddingTop: `${Math.max(1, 2 * scaleY)}px`,
|
||||
}}
|
||||
{...captureStopProps}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
id={`${field.name}_${widget.pageIndex}_${widget.x}_${widget.y}`}
|
||||
value={value}
|
||||
onChange={(e) => onChange(field.name, e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
disabled={field.readOnly}
|
||||
placeholder={field.label}
|
||||
style={inputBaseStyle}
|
||||
aria-label={field.label || field.name}
|
||||
aria-required={field.required}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${field.name}-error` : undefined}
|
||||
{...captureStopProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checkbox": {
|
||||
// Checkbox is checked when value is anything other than 'Off' or empty
|
||||
const isChecked = !!value && value !== "Off";
|
||||
// When toggling on, use the widget's exportValue (e.g. 'Red', 'Blue') or fall back to 'Yes'
|
||||
const onValue = widget.exportValue || "Yes";
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
style={{
|
||||
...commonStyle,
|
||||
border: isActive
|
||||
? commonStyle.border
|
||||
: "1px solid rgba(0,0,0,0.15)",
|
||||
background: isActive ? bgColor : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center", // Keep center for checkboxes as they are usually square hitboxes
|
||||
cursor: field.readOnly ? "default" : "pointer",
|
||||
}}
|
||||
title={error || field.tooltip || field.label}
|
||||
onClick={(e) => {
|
||||
if (field.readOnly) return;
|
||||
handleFocus();
|
||||
onChange(field.name, isChecked ? "Off" : onValue);
|
||||
stopPropagation(e);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: "85%",
|
||||
height: "85%",
|
||||
maxWidth: height * 0.9, // Prevent it from getting too wide in rectangular boxes
|
||||
maxHeight: width * 0.9,
|
||||
fontSize: `${Math.max(10, height * 0.75)}px`,
|
||||
lineHeight: 1,
|
||||
color: isChecked ? "#2196F3" : "transparent",
|
||||
background: "#FFF",
|
||||
border:
|
||||
isChecked || isActive
|
||||
? "1px solid #2196F3"
|
||||
: "1.5px solid #666",
|
||||
borderRadius: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
userSelect: "none",
|
||||
boxShadow: isActive
|
||||
? "0 0 0 2px rgba(33, 150, 243, 0.2)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "combobox":
|
||||
case "listbox": {
|
||||
const inputId = `${field.name}_${widget.pageIndex}_${widget.x}_${widget.y}`;
|
||||
|
||||
// For multi-select, value should be an array
|
||||
// We store as comma-separated string, so parse it
|
||||
const selectValue = field.multiSelect
|
||||
? value
|
||||
? value.split(",").map((v) => v.trim())
|
||||
: []
|
||||
: value;
|
||||
|
||||
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (field.multiSelect) {
|
||||
// For multi-select, join selected options with comma
|
||||
const selected = Array.from(
|
||||
e.target.selectedOptions,
|
||||
(opt) => opt.value,
|
||||
);
|
||||
onChange(field.name, selected.join(","));
|
||||
} else {
|
||||
onChange(field.name, e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...commonProps} title={error || field.tooltip || field.label}>
|
||||
<select
|
||||
id={inputId}
|
||||
value={selectValue}
|
||||
onChange={handleSelectChange}
|
||||
onFocus={handleFocus}
|
||||
disabled={field.readOnly}
|
||||
multiple={field.multiSelect}
|
||||
style={{
|
||||
...inputBaseStyle,
|
||||
padding: 0,
|
||||
paddingLeft: 2,
|
||||
appearance: "auto",
|
||||
WebkitAppearance:
|
||||
"auto" as React.CSSProperties["WebkitAppearance"],
|
||||
}}
|
||||
aria-label={field.label || field.name}
|
||||
aria-required={field.required}
|
||||
aria-invalid={!!error}
|
||||
{...captureStopProps}
|
||||
>
|
||||
{!field.multiSelect && <option value="">— select —</option>}
|
||||
{(field.options || []).map((opt, idx) => (
|
||||
<option key={opt} value={opt}>
|
||||
{(field.displayOptions && field.displayOptions[idx]) || opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "radio": {
|
||||
// Identify this widget by its index within the field's widgets array.
|
||||
// This avoids issues with duplicate exportValues (e.g., all "Yes").
|
||||
const widgetIndex = field.widgets?.indexOf(widget) ?? -1;
|
||||
if (widgetIndex < 0) return null;
|
||||
const widgetIndexStr = String(widgetIndex);
|
||||
const isSelected = value === widgetIndexStr;
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
style={{
|
||||
...commonStyle,
|
||||
border: isActive ? commonStyle.border : "none",
|
||||
background: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start", // Align to start (left) instead of center for radio buttons
|
||||
paddingLeft: Math.max(
|
||||
1,
|
||||
(height - Math.min(width, height) * 0.8) / 2,
|
||||
), // Slight offset
|
||||
cursor: field.readOnly ? "default" : "pointer",
|
||||
}}
|
||||
title={
|
||||
error ||
|
||||
field.tooltip ||
|
||||
`${field.label}: ${widget.exportValue || widgetIndexStr}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (field.readOnly || value === widgetIndexStr) return; // Don't deselect radio buttons
|
||||
handleFocus();
|
||||
onChange(field.name, widgetIndexStr);
|
||||
stopPropagation(e);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: Math.min(width, height) * 0.8,
|
||||
height: Math.min(width, height) * 0.8,
|
||||
borderRadius: "50%",
|
||||
border: `1.5px solid ${isSelected ? "#2196F3" : isActive ? "#2196F3" : "#999"}`,
|
||||
background: isSelected ? "#2196F3" : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxShadow: isSelected ? "inset 0 0 0 2px white" : "none",
|
||||
transition: "background 0.15s, border-color 0.15s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "signature":
|
||||
// Signature fields are handled entirely by SignatureFieldOverlay (bitmap canvas).
|
||||
// Rendering a placeholder here creates a visible grey overlay on top of the
|
||||
// signature appearance, so we skip it entirely.
|
||||
return null;
|
||||
|
||||
case "button": {
|
||||
// Transparent hit-target only — visual appearance is rendered by ButtonAppearanceOverlay
|
||||
// (which paints the PDF's native /AP bitmap onto a canvas behind this div).
|
||||
const buttonLabel =
|
||||
field.buttonLabel || field.value || field.label || "Button";
|
||||
const isClickable = !field.readOnly;
|
||||
|
||||
let actionHint = "";
|
||||
if (field.buttonAction) {
|
||||
switch (field.buttonAction.type) {
|
||||
case "named":
|
||||
actionHint = field.buttonAction.namedAction ?? "";
|
||||
break;
|
||||
case "resetForm":
|
||||
actionHint = "Reset Form";
|
||||
break;
|
||||
case "submitForm":
|
||||
actionHint = `Submit to: ${field.buttonAction.url ?? ""}`.trim();
|
||||
break;
|
||||
case "uri":
|
||||
actionHint = field.buttonAction.url ?? "";
|
||||
break;
|
||||
case "javascript":
|
||||
actionHint = "Script";
|
||||
break;
|
||||
}
|
||||
}
|
||||
const titleText =
|
||||
field.tooltip ||
|
||||
(actionHint ? `${buttonLabel} (${actionHint})` : buttonLabel);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...commonProps}
|
||||
style={{
|
||||
...commonStyle,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
cursor: isClickable ? "pointer" : "default",
|
||||
}}
|
||||
title={titleText}
|
||||
role="button"
|
||||
tabIndex={isClickable ? 0 : -1}
|
||||
aria-label={buttonLabel}
|
||||
onClick={(e) => {
|
||||
handleFocus();
|
||||
if (isClickable) onButtonClick(field, field.buttonAction);
|
||||
stopPropagation(e);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isClickable && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onButtonClick(field, field.buttonAction);
|
||||
}
|
||||
stopPropagation(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return (
|
||||
<div {...commonProps} title={field.tooltip || field.label}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(field.name, e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
disabled={field.readOnly}
|
||||
style={inputBaseStyle}
|
||||
{...captureStopProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const WidgetInput = memo(WidgetInputInner);
|
||||
|
||||
interface FormFieldOverlayProps {
|
||||
documentId: string;
|
||||
pageIndex: number;
|
||||
pageWidth: number; // rendered CSS pixel width (from renderPage callback)
|
||||
pageHeight: number; // rendered CSS pixel height
|
||||
/** File identity — if provided, overlay only renders when context fields match this file */
|
||||
fileId?: string | null;
|
||||
}
|
||||
|
||||
export function FormFieldOverlay({
|
||||
documentId,
|
||||
pageIndex,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
fileId,
|
||||
}: FormFieldOverlayProps) {
|
||||
const { setValue, setActiveField, fieldsByPage, state, forFileId } =
|
||||
useFormFill();
|
||||
const { activeFieldName, validationErrors } = state;
|
||||
const { printActions, scrollActions, exportActions } = useViewer();
|
||||
|
||||
// Get scale from EmbedPDF document state — same pattern as LinkLayer
|
||||
// NOTE: All hooks must be called unconditionally (before any early returns)
|
||||
const documentState = useDocumentState(documentId);
|
||||
|
||||
const { scaleX, scaleY } = useMemo(() => {
|
||||
const pdfPage = documentState?.document?.pages?.[pageIndex];
|
||||
if (!pdfPage || !pdfPage.size || !pageWidth || !pageHeight) {
|
||||
const s = documentState?.scale ?? 1;
|
||||
if (pageIndex === 0) {
|
||||
console.debug(
|
||||
"[FormFieldOverlay] page 0 using fallback scale=%f (missing pdfPage.size)",
|
||||
s,
|
||||
);
|
||||
}
|
||||
return { scaleX: s, scaleY: s };
|
||||
}
|
||||
|
||||
const sx = pageWidth / pdfPage.size.width;
|
||||
const sy = pageHeight / pdfPage.size.height;
|
||||
if (pageIndex === 0) {
|
||||
console.debug(
|
||||
"[FormFieldOverlay] page 0 scale: pageW=%f pageH=%f pdfW=%f pdfH=%f → scaleX=%f scaleY=%f docScale=%f",
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
pdfPage.size.width,
|
||||
pdfPage.size.height,
|
||||
sx,
|
||||
sy,
|
||||
documentState?.scale,
|
||||
);
|
||||
}
|
||||
// pdfPage.size contains un-rotated dimensions from the engine;
|
||||
// pageWidth/pageHeight from Scroller = pdfPage.size * documentScale
|
||||
return { scaleX: sx, scaleY: sy };
|
||||
}, [documentState, pageIndex, pageWidth, pageHeight]);
|
||||
|
||||
const pageFields = useMemo(
|
||||
() => fieldsByPage.get(pageIndex) || [],
|
||||
[fieldsByPage, pageIndex],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(fieldName: string) => setActiveField(fieldName),
|
||||
[setActiveField],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(fieldName: string, value: string) => setValue(fieldName, value),
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(field: FormField, action?: ButtonAction | null) => {
|
||||
const doOpenUrl = (url: string) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (["http:", "https:", "mailto:"].includes(u.protocol)) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch {
|
||||
/* invalid URL */
|
||||
}
|
||||
};
|
||||
const doResetForm = () => {
|
||||
for (const f of state.fields) setValue(f.name, f.value ?? "");
|
||||
};
|
||||
const doSave = () => {
|
||||
exportActions.saveAsCopy();
|
||||
};
|
||||
|
||||
if (!action) {
|
||||
// Action extraction failed — fall back to label matching as a last resort
|
||||
const label = (field.buttonLabel || field.label || "").toLowerCase();
|
||||
if (/print/.test(label)) printActions.print();
|
||||
else if (/save|download/.test(label)) doSave();
|
||||
else if (/reset|clear/.test(label)) doResetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case "named":
|
||||
switch (action.namedAction) {
|
||||
case "Print":
|
||||
printActions.print();
|
||||
break;
|
||||
case "Save":
|
||||
doSave();
|
||||
break;
|
||||
case "NextPage":
|
||||
scrollActions.scrollToNextPage();
|
||||
break;
|
||||
case "PrevPage":
|
||||
scrollActions.scrollToPreviousPage();
|
||||
break;
|
||||
case "FirstPage":
|
||||
scrollActions.scrollToFirstPage();
|
||||
break;
|
||||
case "LastPage":
|
||||
scrollActions.scrollToLastPage();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "resetForm":
|
||||
doResetForm();
|
||||
break;
|
||||
case "submitForm":
|
||||
case "uri":
|
||||
if (action.url) doOpenUrl(action.url);
|
||||
break;
|
||||
case "javascript":
|
||||
// Execute in a sandboxed PDF JS environment instead of just logging
|
||||
if (action.javascript) {
|
||||
executePdfJs(action.javascript, {
|
||||
print: () => printActions.print(),
|
||||
save: doSave,
|
||||
submitForm: doOpenUrl,
|
||||
resetForm: doResetForm,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[printActions, scrollActions, exportActions, state.fields, setValue],
|
||||
);
|
||||
|
||||
// Guard: don't render fields from a previous document.
|
||||
// If fileId is provided and doesn't match what the context fetched for, render nothing.
|
||||
if (fileId != null && forFileId != null && fileId !== forFileId) {
|
||||
return null;
|
||||
}
|
||||
// Also guard: if fields exist but no forFileId is set (reset happened), don't render stale fields
|
||||
if (fileId != null && forFileId == null && state.fields.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pageFields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none", // allow click-through except on widgets
|
||||
zIndex: 5, // above TilingLayer, below LinkLayer
|
||||
}}
|
||||
data-form-overlay-page={pageIndex}
|
||||
>
|
||||
{pageFields.map((field: FormField) =>
|
||||
(field.widgets || [])
|
||||
.filter((w: WidgetCoordinates) => w.pageIndex === pageIndex)
|
||||
.map((widget: WidgetCoordinates, widgetIdx: number) => {
|
||||
// Coordinates are in un-rotated PDF space (y-flipped to CSS TL origin).
|
||||
// The <Rotate> CSS wrapper handles visual rotation for us,
|
||||
// just like it does for TilingLayer, LinkLayer, etc.
|
||||
return (
|
||||
<WidgetInput
|
||||
key={`${field.name}-${widgetIdx}`}
|
||||
field={field}
|
||||
widget={widget}
|
||||
isActive={activeFieldName === field.name}
|
||||
error={validationErrors[field.name]}
|
||||
scaleX={scaleX}
|
||||
scaleY={scaleY}
|
||||
onFocus={handleFocus}
|
||||
onChange={handleChange}
|
||||
onButtonClick={handleButtonClick}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormFieldOverlay;
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* FormFieldSidebar — A right-side panel for viewing and filling form fields
|
||||
* when the dedicated formFill tool is NOT selected (normal viewer mode).
|
||||
*
|
||||
* Redesigned with:
|
||||
* - Consistent CSS module styling matching the main FormFill panel
|
||||
* - Shared FieldInput component (no duplication)
|
||||
* - Better visual hierarchy and spacing
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
ScrollArea,
|
||||
Badge,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormFill } from "@app/tools/formFill/FormFillContext";
|
||||
import { FieldInput } from "@app/tools/formFill/FieldInput";
|
||||
import {
|
||||
FIELD_TYPE_ICON,
|
||||
FIELD_TYPE_COLOR,
|
||||
} from "@app/tools/formFill/fieldMeta";
|
||||
import type { FormField } from "@app/tools/formFill/types";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import TextFieldsIcon from "@mui/icons-material/TextFields";
|
||||
import styles from "@app/tools/formFill/FormFill.module.css";
|
||||
|
||||
interface FormFieldSidebarProps {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
|
||||
useTranslation();
|
||||
const { state, setValue, setActiveField } = useFormFill();
|
||||
const { fields, activeFieldName, loading } = state;
|
||||
const activeFieldRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeFieldName && activeFieldRef.current) {
|
||||
activeFieldRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [activeFieldName]);
|
||||
|
||||
const handleFieldClick = useCallback(
|
||||
(fieldName: string) => {
|
||||
setActiveField(fieldName);
|
||||
},
|
||||
[setActiveField],
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
setValue(fieldName, value);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const fieldsByPage = new Map<number, FormField[]>();
|
||||
for (const field of fields) {
|
||||
const pageIndex =
|
||||
field.widgets && field.widgets.length > 0
|
||||
? field.widgets[0].pageIndex
|
||||
: 0;
|
||||
if (!fieldsByPage.has(pageIndex)) {
|
||||
fieldsByPage.set(pageIndex, []);
|
||||
}
|
||||
fieldsByPage.get(pageIndex)!.push(field);
|
||||
}
|
||||
const sortedPages = Array.from(fieldsByPage.keys()).sort((a, b) => a - b);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: "18.5rem",
|
||||
height: "100%",
|
||||
zIndex: 999,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--bg-toolbar, var(--mantine-color-body))",
|
||||
borderLeft:
|
||||
"1px solid var(--border-subtle, var(--mantine-color-default-border))",
|
||||
boxShadow: "-4px 0 16px rgba(0,0,0,0.08)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "0.625rem 0.75rem",
|
||||
borderBottom:
|
||||
"1px solid var(--border-subtle, var(--mantine-color-default-border))",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||
<TextFieldsIcon sx={{ fontSize: 18, opacity: 0.7 }} />
|
||||
<Text fw={600} size="sm">
|
||||
Form Fields
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="blue" radius="sm">
|
||||
{fields.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onToggle}
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
{loading && (
|
||||
<div className={styles.emptyState}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading form fields...
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && fields.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
<span className={styles.emptyStateText}>
|
||||
No form fields found in this PDF
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && fields.length > 0 && (
|
||||
<div className={styles.fieldListInner}>
|
||||
{sortedPages.map((pageIdx, i) => (
|
||||
<React.Fragment key={pageIdx}>
|
||||
<div
|
||||
className={styles.pageDivider}
|
||||
style={i === 0 ? { marginTop: 0 } : undefined}
|
||||
>
|
||||
<Text className={styles.pageDividerLabel}>
|
||||
Page {pageIdx + 1}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{fieldsByPage.get(pageIdx)!.map((field) => {
|
||||
const isActive = activeFieldName === field.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
ref={isActive ? activeFieldRef : undefined}
|
||||
className={`${styles.fieldCard} ${isActive ? styles.fieldCardActive : ""}`}
|
||||
onClick={() => handleFieldClick(field.name)}
|
||||
>
|
||||
<div className={styles.fieldHeader}>
|
||||
<Tooltip label={field.type} withArrow position="left">
|
||||
<span
|
||||
className={styles.fieldTypeIcon}
|
||||
style={{
|
||||
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{FIELD_TYPE_ICON[field.type]}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className={styles.fieldName}>
|
||||
{field.label || field.name}
|
||||
</span>
|
||||
{field.required && (
|
||||
<span className={styles.fieldRequired}>req</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{field.type !== "button" &&
|
||||
field.type !== "signature" && (
|
||||
<div className={styles.fieldInputWrap}>
|
||||
<FieldInput
|
||||
field={field}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.tooltip && (
|
||||
<div className={styles.fieldHint}>{field.tooltip}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormFieldSidebar;
|
||||
@@ -0,0 +1,307 @@
|
||||
.root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modeTabs {
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid
|
||||
var(--border-default, var(--mantine-color-default-border));
|
||||
background: transparent;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.segmentedRoot {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme="dark"]) .segmentedRoot {
|
||||
background: rgba(255, 255, 255, 0.05) !important;
|
||||
}
|
||||
|
||||
.segmentedIndicator {
|
||||
background-color: var(--mantine-color-blue-filled) !important;
|
||||
box-shadow: var(--shadow-sm) !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
|
||||
.segmentedLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0.125rem 0;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.segmentedInnerLabel {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modeTabIcon {
|
||||
font-size: 1rem !important;
|
||||
margin-bottom: 0.125rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid
|
||||
var(--border-default, var(--mantine-color-default-border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.progressRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progressLabel {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionBar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.primaryActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primaryActions > *:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.secondaryActions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.secondaryActions > button {
|
||||
flex: 1;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.fieldList {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fieldListInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.pageDivider {
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pageDivider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-default, var(--mantine-color-default-border));
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.pageDividerLabel {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fieldCard {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-default, var(--mantine-color-default-border));
|
||||
background: var(--bg-surface, var(--mantine-color-body));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.fieldCard:hover {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.fieldCardActive {
|
||||
border-color: var(--mantine-color-blue-5);
|
||||
background-color: var(--mantine-color-blue-light);
|
||||
box-shadow: inset 0 0 0 1px var(--mantine-color-blue-light-color);
|
||||
}
|
||||
|
||||
.fieldCardError {
|
||||
border-color: var(--mantine-color-red-5);
|
||||
background-color: var(--mantine-color-red-light);
|
||||
}
|
||||
|
||||
.fieldHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.fieldTypeIcon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.fieldName {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fieldRequired {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.5625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--mantine-color-red-light);
|
||||
color: var(--mantine-color-red-light-color);
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fieldInputWrap {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-red-6);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 5rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.emptyStateIcon {
|
||||
font-size: 2.5rem !important;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.emptyStateText {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--mantine-color-yellow-6);
|
||||
margin-right: 0.375rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid
|
||||
var(--border-default, var(--mantine-color-default-border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comingSoon {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.comingSoonIcon {
|
||||
font-size: 3rem !important;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.comingSoonTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.comingSoonDesc {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
max-width: 200px;
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
/**
|
||||
* FormFill: The tool component that renders in the left ToolPanel
|
||||
* when the "Fill Form" tool is selected.
|
||||
*
|
||||
* Redesigned with:
|
||||
* - Mode tabs for future extensibility (Fill / Make / Batch / Modify)
|
||||
* - Clean visual hierarchy with proper spacing
|
||||
* - Shared FieldInput component (eliminates duplication)
|
||||
* - CSS module for theme-consistent styling
|
||||
* - Status bar at bottom for contextual info
|
||||
*/
|
||||
import React, {
|
||||
useEffect,
|
||||
useCallback,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import {
|
||||
Button,
|
||||
Text,
|
||||
Alert,
|
||||
Switch,
|
||||
Loader,
|
||||
ScrollArea,
|
||||
Progress,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
useFormFill,
|
||||
useAllFormValues,
|
||||
} from "@app/tools/formFill/FormFillContext";
|
||||
import { useNavigation } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext";
|
||||
import type { BaseToolProps } from "@app/types/tool";
|
||||
import type { FormField } from "@app/tools/formFill/types";
|
||||
import { FieldInput } from "@app/tools/formFill/FieldInput";
|
||||
import {
|
||||
FIELD_TYPE_ICON,
|
||||
FIELD_TYPE_COLOR,
|
||||
} from "@app/tools/formFill/fieldMeta";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import PostAddIcon from "@mui/icons-material/PostAdd";
|
||||
import FileCopyIcon from "@mui/icons-material/FileCopy";
|
||||
import BuildCircleIcon from "@mui/icons-material/BuildCircle";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import FileDownloadIcon from "@mui/icons-material/FileDownload";
|
||||
import {
|
||||
extractFormFieldsCsv,
|
||||
extractFormFieldsXlsx,
|
||||
} from "@app/tools/formFill/formApi";
|
||||
import styles from "@app/tools/formFill/FormFill.module.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode tabs — extensible for future form tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FormMode = "fill" | "make" | "batch" | "modify";
|
||||
|
||||
interface ModeTabDef {
|
||||
id: FormMode;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const _MODE_TABS: ModeTabDef[] = [
|
||||
{
|
||||
id: "fill",
|
||||
label: "Fill",
|
||||
icon: <EditNoteIcon className={styles.modeTabIcon} />,
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
id: "make",
|
||||
label: "Create",
|
||||
icon: <PostAddIcon className={styles.modeTabIcon} />,
|
||||
ready: false,
|
||||
},
|
||||
{
|
||||
id: "batch",
|
||||
label: "Batch",
|
||||
icon: <FileCopyIcon className={styles.modeTabIcon} />,
|
||||
ready: false,
|
||||
},
|
||||
{
|
||||
id: "modify",
|
||||
label: "Modify",
|
||||
icon: <BuildCircleIcon className={styles.modeTabIcon} />,
|
||||
ready: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coming-soon placeholder for unimplemented tabs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ComingSoonPlaceholder — re-enable when mode tabs are exposed
|
||||
// function ComingSoonPlaceholder({ mode }: { mode: ModeTabDef }) {
|
||||
// return (
|
||||
// <div className={styles.comingSoon}>
|
||||
// <DescriptionIcon className={styles.comingSoonIcon} />
|
||||
// <div className={styles.comingSoonTitle}>{mode.label} Forms</div>
|
||||
// <div className={styles.comingSoonDesc}>
|
||||
// This feature is coming soon. Stay tuned!
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main FormFill component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FormFill = (_props: BaseToolProps) => {
|
||||
const { selectedTool } = useNavigation();
|
||||
const { selectors, state: fileState } = useFileState();
|
||||
|
||||
const {
|
||||
state: formState,
|
||||
fetchFields,
|
||||
submitForm,
|
||||
setValue,
|
||||
setActiveField,
|
||||
validateForm,
|
||||
} = useFormFill();
|
||||
|
||||
const allValues = useAllFormValues();
|
||||
const { validationErrors } = formState;
|
||||
|
||||
const { scrollActions } = useViewer();
|
||||
|
||||
// Mode system is temporarily restricted to 'fill' only.
|
||||
// Other modes (make, batch, modify) are defined above but not yet exposed in the UI.
|
||||
// When ready, uncomment the SegmentedControl and mode state below.
|
||||
// const [mode, setMode] = useState<FormMode>('fill');
|
||||
const mode: FormMode = "fill";
|
||||
const [flatten, setFlatten] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [extracting, setExtracting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const [lastSavedFlatten, setLastSavedFlatten] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const flattenChanged =
|
||||
lastSavedFlatten !== null && flatten !== lastSavedFlatten;
|
||||
|
||||
const savingRef = useRef(false);
|
||||
|
||||
const handleExtractJson = useCallback(() => {
|
||||
setExtracting(true);
|
||||
try {
|
||||
const data = JSON.stringify(allValues, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `form-data-${new Date().getTime()}.json`;
|
||||
a.click();
|
||||
// Delay revocation so the browser has time to start the download
|
||||
setTimeout(() => URL.revokeObjectURL(url), 250);
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
}, [allValues]);
|
||||
const activeFieldRef = useRef<HTMLDivElement>(null);
|
||||
const isDirtyRef = useRef(formState.isDirty);
|
||||
isDirtyRef.current = formState.isDirty;
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
const selectedFileIds = fileState.ui.selectedFileIds;
|
||||
const currentFile = useMemo(() => {
|
||||
if (activeFiles.length === 0) return null;
|
||||
if (selectedFileIds.length > 0) {
|
||||
const sel = activeFiles.find(
|
||||
(f) => isStirlingFile(f) && selectedFileIds.includes(f.fileId),
|
||||
);
|
||||
if (sel) return sel;
|
||||
}
|
||||
return activeFiles[0];
|
||||
}, [activeFiles, selectedFileIds]);
|
||||
|
||||
const handleExtractCsv = useCallback(async () => {
|
||||
if (!currentFile) return;
|
||||
setExtracting(true);
|
||||
try {
|
||||
const blob = await extractFormFieldsCsv(currentFile, allValues);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `form-data-${new Date().getTime()}.csv`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 250);
|
||||
} catch (err) {
|
||||
console.error("[FormFill] CSV extraction failed:", err);
|
||||
setSaveError("Failed to extract CSV");
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
}, [currentFile, allValues]);
|
||||
|
||||
const handleExtractXlsx = useCallback(async () => {
|
||||
if (!currentFile) return;
|
||||
setExtracting(true);
|
||||
try {
|
||||
const blob = await extractFormFieldsXlsx(currentFile, allValues);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `form-data-${new Date().getTime()}.xlsx`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 250);
|
||||
} catch (err) {
|
||||
console.error("[FormFill] XLSX extraction failed:", err);
|
||||
setSaveError("Failed to extract XLSX");
|
||||
} finally {
|
||||
setExtracting(false);
|
||||
}
|
||||
}, [currentFile, allValues]);
|
||||
|
||||
const isActive = selectedTool === "formFill";
|
||||
|
||||
useEffect(() => {
|
||||
if (formState.activeFieldName && activeFieldRef.current) {
|
||||
activeFieldRef.current.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
}, [formState.activeFieldName]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Ref-based guard prevents concurrent saves that cause file duplication
|
||||
if (savingRef.current) return;
|
||||
if (!currentFile || !isStirlingFile(currentFile)) return;
|
||||
|
||||
if (!validateForm()) {
|
||||
setSaveError("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const filledBlob = await submitForm(currentFile, flatten);
|
||||
|
||||
// Track the flatten value at save so toggling it later re-enables Save
|
||||
setLastSavedFlatten(flatten);
|
||||
|
||||
// Dispatch to the viewer's handleFormApply via custom event.
|
||||
// This ensures the viewer tracks the new file ID, preserves
|
||||
// scroll position and rotation — instead of our own consumeFiles
|
||||
// call which would lose the viewer's file tracking context.
|
||||
const event = new CustomEvent("formfill:apply", {
|
||||
detail: { blob: filledBlob },
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err?.response?.status === 413
|
||||
? "File too large. Try reducing the PDF size first."
|
||||
: err?.response?.status === 400
|
||||
? "Invalid form data. Please check all fields."
|
||||
: err?.message || "Failed to save filled form";
|
||||
setSaveError(message);
|
||||
console.error("[FormFill] Save failed:", err);
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}, [currentFile, submitForm, flatten, validateForm]);
|
||||
|
||||
// Keyboard shortcut: Ctrl+S to save
|
||||
const flattenChangedRef = useRef(flattenChanged);
|
||||
flattenChangedRef.current = flattenChanged;
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (isDirtyRef.current || flattenChangedRef.current) handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isActive, handleSave]);
|
||||
|
||||
// Data loss prevention: warn on beforeunload if dirty
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (formState.isDirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [formState.isDirty]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (currentFile) {
|
||||
fetchFields(currentFile, getFormFillFileId(currentFile) ?? undefined);
|
||||
}
|
||||
}, [currentFile, fetchFields]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
setValue(fieldName, value);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const handleFieldClick = useCallback(
|
||||
(fieldName: string, pageIndex?: number) => {
|
||||
setActiveField(fieldName);
|
||||
if (pageIndex !== undefined) {
|
||||
scrollActions.scrollToPage(pageIndex + 1);
|
||||
}
|
||||
},
|
||||
[setActiveField, scrollActions],
|
||||
);
|
||||
|
||||
// Progress tracking
|
||||
const fillableFields = useMemo(() => {
|
||||
return formState.fields.filter(
|
||||
(f) => f.type !== "button" && f.type !== "signature",
|
||||
);
|
||||
}, [formState.fields]);
|
||||
|
||||
// Memoize fillable fields grouped by page (signatures/buttons excluded)
|
||||
const { sortedPages, fieldsByPage } = useMemo(() => {
|
||||
const byPage = new Map<number, FormField[]>();
|
||||
for (const field of fillableFields) {
|
||||
const pageIndex =
|
||||
field.widgets && field.widgets.length > 0
|
||||
? field.widgets[0].pageIndex
|
||||
: 0;
|
||||
if (!byPage.has(pageIndex)) {
|
||||
byPage.set(pageIndex, []);
|
||||
}
|
||||
byPage.get(pageIndex)!.push(field);
|
||||
}
|
||||
const pages = Array.from(byPage.keys()).sort((a, b) => a - b);
|
||||
return { sortedPages: pages, fieldsByPage: byPage };
|
||||
}, [fillableFields]);
|
||||
|
||||
const fillableCount = fillableFields.length;
|
||||
|
||||
const filledCount = useMemo(() => {
|
||||
return fillableFields.filter((f) => {
|
||||
const v = allValues[f.name];
|
||||
return v && v !== "Off" && v.trim() !== "";
|
||||
}).length;
|
||||
}, [fillableFields, allValues]);
|
||||
|
||||
const requiredFields = useMemo(() => {
|
||||
return fillableFields.filter((f) => f.required);
|
||||
}, [fillableFields]);
|
||||
|
||||
const requiredCount = requiredFields.length;
|
||||
|
||||
const filledRequiredCount = useMemo(() => {
|
||||
return requiredFields.filter((f) => {
|
||||
const v = allValues[f.name];
|
||||
return v && v !== "Off" && v.trim() !== "";
|
||||
}).length;
|
||||
}, [requiredFields, allValues]);
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
// const currentModeDef = MODE_TABS.find((t) => t.id === mode)!;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* ---- Mode selection (commented out until additional modes are implemented) ----
|
||||
<div className={styles.modeTabs}>
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(val) => setMode(val as FormMode)}
|
||||
data={MODE_TABS.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: (
|
||||
<div className={styles.segmentedLabel}>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
fullWidth
|
||||
radius="xs"
|
||||
size="xs"
|
||||
classNames={{
|
||||
root: styles.segmentedRoot,
|
||||
indicator: styles.segmentedIndicator,
|
||||
control: styles.segmentedControl,
|
||||
label: styles.segmentedInnerLabel,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
---- */}
|
||||
|
||||
{/* ---- Coming-soon for non-ready tabs (hidden while mode tabs are disabled) ---- */}
|
||||
{/* !currentModeDef.ready && <ComingSoonPlaceholder mode={currentModeDef} /> */}
|
||||
|
||||
{/* ---- Fill Form content ---- */}
|
||||
{mode === "fill" && (
|
||||
<>
|
||||
{/* Header / controls */}
|
||||
<div className={styles.header}>
|
||||
{/* Loading state */}
|
||||
{formState.loading && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Loader size={14} />
|
||||
<Text size="xs" c="dimmed">
|
||||
Analysing form fields...
|
||||
</Text>
|
||||
</div>
|
||||
<Skeleton height={48} radius="sm" />
|
||||
<Skeleton height={48} radius="sm" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{formState.error && (
|
||||
<Alert
|
||||
icon={<WarningAmberIcon sx={{ fontSize: 16 }} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
p="xs"
|
||||
radius="sm"
|
||||
>
|
||||
<Text size="xs">{formState.error}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Ready state with fields */}
|
||||
{!formState.loading && formState.fields.length > 0 && (
|
||||
<>
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className={styles.progressRow}>
|
||||
<span className={styles.progressLabel}>
|
||||
{filledCount} / {fillableCount} filled
|
||||
{requiredCount > 0 && (
|
||||
<span style={{ marginLeft: "0.5rem", opacity: 0.7 }}>
|
||||
({filledRequiredCount}/{requiredCount} req.)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.progressLabel}>
|
||||
{fillableCount > 0
|
||||
? Math.round((filledCount / fillableCount) * 100)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
fillableCount > 0
|
||||
? (filledCount / fillableCount) * 100
|
||||
: 0
|
||||
}
|
||||
size={6}
|
||||
radius="xl"
|
||||
color={
|
||||
filledRequiredCount === requiredCount ? "teal" : "blue"
|
||||
}
|
||||
mt={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flatten toggle */}
|
||||
<Switch
|
||||
label="Flatten after filling"
|
||||
checked={flatten}
|
||||
onChange={(e) => setFlatten(e.currentTarget.checked)}
|
||||
size="xs"
|
||||
styles={{
|
||||
label: { fontSize: "0.75rem", cursor: "pointer" },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className={styles.actionBar}>
|
||||
<div className={styles.primaryActions}>
|
||||
<Button
|
||||
leftSection={<SaveIcon sx={{ fontSize: 14 }} />}
|
||||
size="xs"
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!formState.isDirty && !flattenChanged}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<Tooltip label="Re-scan fields" withArrow position="bottom">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="md"
|
||||
onClick={handleRefresh}
|
||||
aria-label="Re-scan form fields"
|
||||
>
|
||||
<RefreshIcon sx={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.secondaryActions}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractJson}
|
||||
size="xs"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractCsv}
|
||||
size="xs"
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<FileDownloadIcon sx={{ fontSize: 14 }} />}
|
||||
loading={extracting}
|
||||
onClick={handleExtractXlsx}
|
||||
size="xs"
|
||||
>
|
||||
XLSX
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{saveError && (
|
||||
<Alert color="red" variant="light" p="xs" radius="sm">
|
||||
<Text size="xs">{saveError}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!formState.loading &&
|
||||
formState.fields.length === 0 &&
|
||||
!formState.error && (
|
||||
<div className={styles.emptyState}>
|
||||
<DescriptionIcon className={styles.emptyStateIcon} />
|
||||
<span className={styles.emptyStateText}>
|
||||
No fillable form fields found in this PDF.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- Scrollable field list ---- */}
|
||||
{!formState.loading && formState.fields.length > 0 && (
|
||||
<ScrollArea className={styles.fieldList}>
|
||||
<div className={styles.fieldListInner}>
|
||||
{sortedPages.map((pageIdx, i) => (
|
||||
<React.Fragment key={pageIdx}>
|
||||
<div
|
||||
className={styles.pageDivider}
|
||||
style={i === 0 ? { marginTop: 0 } : undefined}
|
||||
>
|
||||
<Text className={styles.pageDividerLabel}>
|
||||
Page {pageIdx + 1}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{fieldsByPage.get(pageIdx)!.map((field) => {
|
||||
const isFieldActive =
|
||||
formState.activeFieldName === field.name;
|
||||
const hasError = !!validationErrors[field.name];
|
||||
const pageIndex =
|
||||
field.widgets && field.widgets.length > 0
|
||||
? field.widgets[0].pageIndex
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
ref={isFieldActive ? activeFieldRef : undefined}
|
||||
className={`${styles.fieldCard} ${
|
||||
isFieldActive ? styles.fieldCardActive : ""
|
||||
} ${hasError ? styles.fieldCardError : ""}`}
|
||||
onClick={() =>
|
||||
handleFieldClick(field.name, pageIndex)
|
||||
}
|
||||
>
|
||||
<div className={styles.fieldHeader}>
|
||||
<span
|
||||
className={styles.fieldTypeIcon}
|
||||
style={{
|
||||
color: `var(--mantine-color-${FIELD_TYPE_COLOR[field.type]}-6)`,
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{FIELD_TYPE_ICON[field.type]}
|
||||
</span>
|
||||
<span className={styles.fieldName}>
|
||||
{field.label || field.name}
|
||||
</span>
|
||||
{field.required && (
|
||||
<span className={styles.fieldRequired}>req</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{field.type !== "button" &&
|
||||
field.type !== "signature" && (
|
||||
<div className={styles.fieldInputWrap}>
|
||||
<FieldInput
|
||||
field={field}
|
||||
onValueChange={handleValueChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className={styles.fieldError}>
|
||||
{validationErrors[field.name]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.tooltip && (
|
||||
<div className={styles.fieldHint}>
|
||||
{field.tooltip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* ---- Status bar ---- */}
|
||||
{!formState.loading && formState.fields.length > 0 && (
|
||||
<div className={styles.statusBar}>
|
||||
<span>
|
||||
{(formState.isDirty || flattenChanged) && (
|
||||
<span className={styles.unsavedDot} />
|
||||
)}
|
||||
{formState.isDirty || flattenChanged
|
||||
? "Unsaved changes"
|
||||
: "All saved"}
|
||||
</span>
|
||||
<span>Ctrl+S to save</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormFill;
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* FormFillContext — React context for form fill state management.
|
||||
*
|
||||
* Provider-agnostic: delegates data fetching/saving to an IFormDataProvider.
|
||||
* - PdfLibFormProvider: frontend-only, uses pdf-lib (for normal viewer mode)
|
||||
* - PdfBoxFormProvider: backend API via PDFBox (for dedicated formFill tool)
|
||||
*
|
||||
* The active provider can be switched at runtime via setProvider(). This allows
|
||||
* EmbedPdfViewer to auto-select:
|
||||
* - Normal viewer → PdfLibFormProvider (no backend calls for large PDFs)
|
||||
* - formFill tool → PdfBoxFormProvider (full-fidelity PDFBox handling)
|
||||
*
|
||||
* Performance Architecture:
|
||||
* Form values are stored in a FormValuesStore (external to React state) to
|
||||
* avoid full context re-renders on every keystroke. Individual widgets
|
||||
* subscribe to their specific field via useFieldValue() + useSyncExternalStore,
|
||||
* so only the active widget re-renders when its value changes.
|
||||
*
|
||||
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
|
||||
* this context regardless of which provider is active.
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import type {
|
||||
FormField,
|
||||
FormFillState,
|
||||
WidgetCoordinates,
|
||||
} from "@app/tools/formFill/types";
|
||||
import type { IFormDataProvider } from "@app/tools/formFill/providers/types";
|
||||
import { PdfBoxFormProvider } from "@app/tools/formFill/providers/PdfBoxFormProvider";
|
||||
import { PdfiumFormProvider } from "@app/tools/formFill/providers/PdfiumFormProvider";
|
||||
import { fetchSignatureFieldsWithAppearances } from "@app/services/pdfiumService";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FormValuesStore — external store for field values (outside React state)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
/**
|
||||
* External store that holds form values outside of React state.
|
||||
*
|
||||
* This avoids triggering full context re-renders on every keystroke.
|
||||
* Components subscribe per-field via useSyncExternalStore, so only
|
||||
* the widget being edited re-renders.
|
||||
*/
|
||||
class FormValuesStore {
|
||||
private _fieldListeners = new Map<string, Set<Listener>>();
|
||||
private _globalListeners = new Set<Listener>();
|
||||
|
||||
private _values: Record<string, string> = {};
|
||||
|
||||
get values(): Record<string, string> {
|
||||
return this._values;
|
||||
}
|
||||
|
||||
private _version = 0;
|
||||
|
||||
get version(): number {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
getValue(fieldName: string): string {
|
||||
return this._values[fieldName] ?? "";
|
||||
}
|
||||
|
||||
setValue(fieldName: string, value: string): void {
|
||||
if (this._values[fieldName] === value) return;
|
||||
this._values[fieldName] = value;
|
||||
this._version++;
|
||||
this._fieldListeners.get(fieldName)?.forEach((l) => l());
|
||||
this._globalListeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Replace all values (e.g., on fetch or reset) */
|
||||
reset(values: Record<string, string> = {}): void {
|
||||
this._values = values;
|
||||
this._version++;
|
||||
for (const listeners of this._fieldListeners.values()) {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
this._globalListeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** Subscribe to a single field's value changes */
|
||||
subscribeField(fieldName: string, listener: Listener): () => void {
|
||||
if (!this._fieldListeners.has(fieldName)) {
|
||||
this._fieldListeners.set(fieldName, new Set());
|
||||
}
|
||||
this._fieldListeners.get(fieldName)!.add(listener);
|
||||
return () => {
|
||||
this._fieldListeners.get(fieldName)?.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Subscribe to any value change */
|
||||
subscribeGlobal(listener: Listener): () => void {
|
||||
this._globalListeners.add(listener);
|
||||
return () => {
|
||||
this._globalListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer — handles everything EXCEPT values (which live in FormValuesStore)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Action =
|
||||
| { type: "FETCH_START" }
|
||||
| { type: "FETCH_SUCCESS"; fields: FormField[] }
|
||||
| { type: "FETCH_ERROR"; error: string }
|
||||
| { type: "MARK_DIRTY" }
|
||||
| { type: "SET_ACTIVE_FIELD"; fieldName: string | null }
|
||||
| { type: "SET_VALIDATION_ERRORS"; errors: Record<string, string> }
|
||||
| { type: "CLEAR_VALIDATION_ERROR"; fieldName: string }
|
||||
| { type: "MARK_CLEAN" }
|
||||
| { type: "RESET" };
|
||||
|
||||
const initialState: FormFillState = {
|
||||
fields: [],
|
||||
values: {}, // kept for backward compat but canonical values live in FormValuesStore
|
||||
loading: false,
|
||||
error: null,
|
||||
activeFieldName: null,
|
||||
isDirty: false,
|
||||
validationErrors: {},
|
||||
};
|
||||
|
||||
function reducer(state: FormFillState, action: Action): FormFillState {
|
||||
switch (action.type) {
|
||||
case "FETCH_START":
|
||||
return { ...state, loading: true, error: null };
|
||||
case "FETCH_SUCCESS": {
|
||||
return {
|
||||
...state,
|
||||
fields: action.fields,
|
||||
values: {}, // values managed by FormValuesStore
|
||||
loading: false,
|
||||
error: null,
|
||||
isDirty: false,
|
||||
};
|
||||
}
|
||||
case "FETCH_ERROR":
|
||||
return { ...state, loading: false, error: action.error };
|
||||
case "MARK_DIRTY":
|
||||
if (state.isDirty) return state; // avoid unnecessary re-render
|
||||
return { ...state, isDirty: true };
|
||||
case "SET_ACTIVE_FIELD":
|
||||
return { ...state, activeFieldName: action.fieldName };
|
||||
case "SET_VALIDATION_ERRORS":
|
||||
return { ...state, validationErrors: action.errors };
|
||||
case "CLEAR_VALIDATION_ERROR": {
|
||||
if (!state.validationErrors[action.fieldName]) return state;
|
||||
const { [action.fieldName]: _, ...rest } = state.validationErrors;
|
||||
return { ...state, validationErrors: rest };
|
||||
}
|
||||
case "MARK_CLEAN":
|
||||
return { ...state, isDirty: false };
|
||||
case "RESET":
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FormFillContextValue {
|
||||
state: FormFillState;
|
||||
/** Fetch form fields for the given file using the active provider */
|
||||
fetchFields: (file: File | Blob, fileId?: string) => Promise<void>;
|
||||
/** Update a single field value */
|
||||
setValue: (fieldName: string, value: string) => void;
|
||||
/** Set the currently focused field */
|
||||
setActiveField: (fieldName: string | null) => void;
|
||||
/** Submit filled form and return the filled PDF blob */
|
||||
submitForm: (file: File | Blob, flatten?: boolean) => Promise<Blob>;
|
||||
/** Get field by name */
|
||||
getField: (fieldName: string) => FormField | undefined;
|
||||
/** Get fields for a specific page index */
|
||||
getFieldsForPage: (pageIndex: number) => FormField[];
|
||||
/** Get the current value for a field (reads from external store) */
|
||||
getValue: (fieldName: string) => string;
|
||||
/** Validate the current form state and return true if valid */
|
||||
validateForm: () => boolean;
|
||||
/** Clear all form state (fields, values, errors) */
|
||||
reset: () => void;
|
||||
/** Pre-computed map of page index to fields for performance */
|
||||
fieldsByPage: Map<number, FormField[]>;
|
||||
/** Name of the currently active provider ('pdf-lib' | 'pdfbox') */
|
||||
activeProviderName: string;
|
||||
/**
|
||||
* Switch the active data provider.
|
||||
* Use 'pdflib' for frontend-only pdf-lib, 'pdfbox' for backend PDFBox.
|
||||
* Resets form state when switching providers.
|
||||
*/
|
||||
setProviderMode: (mode: "pdflib" | "pdfbox") => void;
|
||||
/** The file ID that the current form fields belong to (null if no fields loaded) */
|
||||
forFileId: string | null;
|
||||
}
|
||||
|
||||
const FormFillContext = createContext<FormFillContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Separate context for the values store.
|
||||
* This allows useFieldValue() to subscribe without depending on the main context.
|
||||
*/
|
||||
const FormValuesStoreContext = createContext<FormValuesStore | null>(null);
|
||||
|
||||
export const useFormFill = (): FormFillContextValue => {
|
||||
const ctx = useContext(FormFillContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useFormFill must be used within a FormFillProvider");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a single field's value. Only re-renders when that specific
|
||||
* field's value changes — not when any other form value changes.
|
||||
*
|
||||
* Uses useSyncExternalStore for tear-free reads.
|
||||
*/
|
||||
export function useFieldValue(fieldName: string): string {
|
||||
const store = useContext(FormValuesStoreContext);
|
||||
if (!store) {
|
||||
throw new Error("useFieldValue must be used within a FormFillProvider");
|
||||
}
|
||||
|
||||
const subscribe = useCallback(
|
||||
(cb: () => void) => store.subscribeField(fieldName, cb),
|
||||
[store, fieldName],
|
||||
);
|
||||
const getSnapshot = useCallback(
|
||||
() => store.getValue(fieldName),
|
||||
[store, fieldName],
|
||||
);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all values (e.g., for progress counters or form submission).
|
||||
* Re-renders on every value change — use sparingly.
|
||||
*/
|
||||
export function useAllFormValues(): Record<string, string> {
|
||||
const store = useContext(FormValuesStoreContext);
|
||||
if (!store) {
|
||||
throw new Error("useAllFormValues must be used within a FormFillProvider");
|
||||
}
|
||||
|
||||
const subscribe = useCallback(
|
||||
(cb: () => void) => store.subscribeGlobal(cb),
|
||||
[store],
|
||||
);
|
||||
const getSnapshot = useCallback(() => store.values, [store]);
|
||||
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
/** Singleton provider instances */
|
||||
const pdfiumProvider = new PdfiumFormProvider();
|
||||
const pdfBoxProvider = new PdfBoxFormProvider();
|
||||
|
||||
export function FormFillProvider({
|
||||
children,
|
||||
provider: providerProp,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Override the initial provider. If not given, defaults to pdf-lib. */
|
||||
provider?: IFormDataProvider;
|
||||
}) {
|
||||
const initialMode = providerProp?.name === "pdfbox" ? "pdfbox" : "pdflib";
|
||||
const [providerMode, setProviderModeState] = useState<"pdflib" | "pdfbox">(
|
||||
initialMode,
|
||||
);
|
||||
const providerModeRef = useRef(initialMode as "pdflib" | "pdfbox");
|
||||
providerModeRef.current = providerMode;
|
||||
const provider =
|
||||
providerProp ??
|
||||
(providerMode === "pdfbox" ? pdfBoxProvider : pdfiumProvider);
|
||||
const providerRef = useRef(provider);
|
||||
providerRef.current = provider;
|
||||
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const fieldsRef = useRef<FormField[]>([]);
|
||||
fieldsRef.current = state.fields;
|
||||
|
||||
// Version counter to cancel stale async fetch responses.
|
||||
// Incremented on every fetchFields() and reset() call.
|
||||
const fetchVersionRef = useRef(0);
|
||||
|
||||
// Track which file the current fields belong to
|
||||
const forFileIdRef = useRef<string | null>(null);
|
||||
const [forFileId, setForFileId] = useState<string | null>(null);
|
||||
|
||||
// External values store — values live HERE, not in the reducer.
|
||||
// This prevents full context re-renders on every keystroke.
|
||||
const [valuesStore] = useState(() => new FormValuesStore());
|
||||
|
||||
const fetchFields = useCallback(
|
||||
async (file: File | Blob, fileId?: string) => {
|
||||
// Increment version so any in-flight fetch for a previous file is discarded.
|
||||
// NOTE: setProviderMode() also increments fetchVersionRef to invalidate
|
||||
// in-flight fetches when switching providers. This is intentional — the
|
||||
// fetch started here captures the NEW version, so stale results are
|
||||
// correctly discarded.
|
||||
const version = ++fetchVersionRef.current;
|
||||
|
||||
// Immediately clear previous state so FormFieldOverlay's stale-file guards
|
||||
// prevent rendering fields from a previous document during the fetch.
|
||||
forFileIdRef.current = null;
|
||||
setForFileId(null);
|
||||
valuesStore.reset({});
|
||||
dispatch({ type: "RESET" });
|
||||
dispatch({ type: "FETCH_START" });
|
||||
try {
|
||||
let fields = await providerRef.current.fetchFields(file);
|
||||
// If another fetch or reset happened while we were waiting, discard this result
|
||||
if (fetchVersionRef.current !== version) {
|
||||
console.debug(
|
||||
"[FormFill] Discarding stale fetch result (version mismatch)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// When the pdfbox provider is active the backend doesn't return signature fields
|
||||
// (they're not fillable). Fetch them via pdflib so their appearances still render.
|
||||
if (providerModeRef.current === "pdfbox") {
|
||||
try {
|
||||
// Convert File/Blob to ArrayBuffer for pdfiumService
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const sigFields =
|
||||
await fetchSignatureFieldsWithAppearances(arrayBuffer);
|
||||
if (fetchVersionRef.current !== version) return; // stale check after async
|
||||
if (sigFields.length > 0) {
|
||||
fields = [...fields, ...sigFields];
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[FormFill] Failed to extract signature appearances for pdfbox mode:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialise values in the external store
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
values[field.name] = field.value ?? "";
|
||||
}
|
||||
valuesStore.reset(values);
|
||||
forFileIdRef.current = fileId ?? null;
|
||||
setForFileId(fileId ?? null);
|
||||
dispatch({ type: "FETCH_SUCCESS", fields });
|
||||
} catch (err: any) {
|
||||
if (fetchVersionRef.current !== version) return; // stale
|
||||
const msg =
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
"Failed to fetch form fields";
|
||||
dispatch({ type: "FETCH_ERROR", error: msg });
|
||||
}
|
||||
},
|
||||
[valuesStore],
|
||||
);
|
||||
|
||||
const validateFieldDebounced = useDebouncedCallback((fieldName: string) => {
|
||||
const field = fieldsRef.current.find((f) => f.name === fieldName);
|
||||
if (!field || !field.required) return;
|
||||
|
||||
const val = valuesStore.getValue(fieldName);
|
||||
if (!val || val.trim() === "" || val === "Off") {
|
||||
dispatch({
|
||||
type: "SET_VALIDATION_ERRORS",
|
||||
errors: {
|
||||
...state.validationErrors,
|
||||
[fieldName]: `${field.label} is required`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: "CLEAR_VALIDATION_ERROR", fieldName });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of fieldsRef.current) {
|
||||
const val = valuesStore.getValue(field.name);
|
||||
if (field.required && (!val || val.trim() === "" || val === "Off")) {
|
||||
errors[field.name] = `${field.label} is required`;
|
||||
}
|
||||
}
|
||||
dispatch({ type: "SET_VALIDATION_ERRORS", errors });
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [valuesStore]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
// Update external store (triggers per-field subscribers only)
|
||||
valuesStore.setValue(fieldName, value);
|
||||
// Mark form as dirty in React state (only triggers re-render once)
|
||||
dispatch({ type: "MARK_DIRTY" });
|
||||
validateFieldDebounced(fieldName);
|
||||
},
|
||||
[valuesStore, validateFieldDebounced],
|
||||
);
|
||||
|
||||
const setActiveField = useCallback((fieldName: string | null) => {
|
||||
dispatch({ type: "SET_ACTIVE_FIELD", fieldName });
|
||||
}, []);
|
||||
|
||||
const submitForm = useCallback(
|
||||
async (file: File | Blob, flatten = false) => {
|
||||
const blob = await providerRef.current.fillForm(
|
||||
file,
|
||||
valuesStore.values,
|
||||
flatten,
|
||||
);
|
||||
dispatch({ type: "MARK_CLEAN" });
|
||||
return blob;
|
||||
},
|
||||
[valuesStore],
|
||||
);
|
||||
|
||||
const setProviderMode = useCallback(
|
||||
(mode: "pdflib" | "pdfbox") => {
|
||||
// Use the ref to check the current mode synchronously — avoids
|
||||
// relying on stale closure state and allows the early return.
|
||||
if (providerModeRef.current === mode) return;
|
||||
|
||||
// provider (pdfbox vs pdflib).
|
||||
const newProvider = mode === "pdfbox" ? pdfBoxProvider : pdfiumProvider;
|
||||
providerRef.current = newProvider;
|
||||
providerModeRef.current = mode;
|
||||
|
||||
fetchVersionRef.current++;
|
||||
forFileIdRef.current = null;
|
||||
setForFileId(null);
|
||||
valuesStore.reset({});
|
||||
dispatch({ type: "RESET" });
|
||||
|
||||
setProviderModeState(mode);
|
||||
},
|
||||
[valuesStore],
|
||||
);
|
||||
|
||||
const getField = useCallback(
|
||||
(fieldName: string) => fieldsRef.current.find((f) => f.name === fieldName),
|
||||
[],
|
||||
);
|
||||
|
||||
const getFieldsForPage = useCallback(
|
||||
(pageIndex: number) =>
|
||||
fieldsRef.current.filter((f) =>
|
||||
f.widgets?.some((w: WidgetCoordinates) => w.pageIndex === pageIndex),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const getValue = useCallback(
|
||||
(fieldName: string) => valuesStore.getValue(fieldName),
|
||||
[valuesStore],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
// Increment version to invalidate any in-flight fetch
|
||||
fetchVersionRef.current++;
|
||||
forFileIdRef.current = null;
|
||||
setForFileId(null);
|
||||
valuesStore.reset({});
|
||||
dispatch({ type: "RESET" });
|
||||
}, [valuesStore]);
|
||||
|
||||
const fieldsByPage = useMemo(() => {
|
||||
const map = new Map<number, FormField[]>();
|
||||
for (const field of state.fields) {
|
||||
const pageIdx = field.widgets?.[0]?.pageIndex ?? 0;
|
||||
if (!map.has(pageIdx)) map.set(pageIdx, []);
|
||||
map.get(pageIdx)!.push(field);
|
||||
}
|
||||
return map;
|
||||
}, [state.fields]);
|
||||
|
||||
// Context value — does NOT depend on values, so keystrokes don't
|
||||
// trigger re-renders of all context consumers.
|
||||
const value = useMemo<FormFillContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
fetchFields,
|
||||
setValue,
|
||||
setActiveField,
|
||||
submitForm,
|
||||
getField,
|
||||
getFieldsForPage,
|
||||
getValue,
|
||||
validateForm,
|
||||
reset,
|
||||
fieldsByPage,
|
||||
activeProviderName: providerRef.current.name,
|
||||
setProviderMode,
|
||||
forFileId,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
fetchFields,
|
||||
setValue,
|
||||
setActiveField,
|
||||
submitForm,
|
||||
getField,
|
||||
getFieldsForPage,
|
||||
getValue,
|
||||
validateForm,
|
||||
reset,
|
||||
fieldsByPage,
|
||||
providerMode,
|
||||
setProviderMode,
|
||||
forFileId,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormValuesStoreContext.Provider value={valuesStore}>
|
||||
<FormFillContext.Provider value={value}>
|
||||
{children}
|
||||
</FormFillContext.Provider>
|
||||
</FormValuesStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormFillContext;
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* FormSaveBar — A notification banner for form-filled PDFs.
|
||||
*
|
||||
* Appears at the top-right of the PDF viewer when the current PDF has
|
||||
* fillable form fields. Provides options to apply changes or download
|
||||
* the filled PDF.
|
||||
*
|
||||
* This component is used in normal viewer mode (pdf-lib provider) where
|
||||
* the dedicated FormFill tool panel is NOT active. It provides a clean
|
||||
* save UX that users expect from browser PDF viewers.
|
||||
*/
|
||||
import React, { useCallback, useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Button,
|
||||
Transition,
|
||||
CloseButton,
|
||||
Paper,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import { useFormFill } from "@app/tools/formFill/FormFillContext";
|
||||
|
||||
interface FormSaveBarProps {
|
||||
/** The current file being viewed */
|
||||
file: File | Blob | null;
|
||||
/** Whether the formFill tool is active (bar is hidden when tool panel is showing) */
|
||||
isFormFillToolActive: boolean;
|
||||
/** Callback when form changes are applied (should reload PDF with filled values) */
|
||||
onApply?: (filledBlob: Blob) => Promise<void>;
|
||||
}
|
||||
|
||||
export function FormSaveBar({
|
||||
file,
|
||||
isFormFillToolActive,
|
||||
onApply,
|
||||
}: FormSaveBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, submitForm } = useFormFill();
|
||||
const { fields, isDirty, loading } = state;
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
// Reset dismissed state when file changes
|
||||
const [prevFile, setPrevFile] = useState<File | Blob | null>(null);
|
||||
if (file !== prevFile) {
|
||||
setPrevFile(file);
|
||||
setDismissed(false);
|
||||
}
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!file || applying || saving) return;
|
||||
setApplying(true);
|
||||
try {
|
||||
// Generate the filled PDF
|
||||
const filledBlob = await submitForm(file, false);
|
||||
|
||||
// Call the onApply callback to reload the PDF in the viewer
|
||||
if (onApply) {
|
||||
await onApply(filledBlob);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[FormSaveBar] Apply failed:", err);
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, [file, applying, saving, submitForm, onApply]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!file || saving || applying) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = await submitForm(file, false);
|
||||
// Trigger browser download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file instanceof File ? file.name : "filled-form.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("[FormSaveBar] Download failed:", err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [file, saving, applying, submitForm]);
|
||||
|
||||
// Don't show when:
|
||||
// - formFill tool is active (it has its own save panel)
|
||||
// - no form fields found
|
||||
// - still loading
|
||||
// - user dismissed the bar
|
||||
const hasFields = fields.some(
|
||||
(f) => f.type !== "signature" && f.type !== "button",
|
||||
);
|
||||
const visible = !isFormFillToolActive && hasFields && !loading && !dismissed;
|
||||
|
||||
return (
|
||||
<Transition mounted={visible} transition="slide-down" duration={300}>
|
||||
{(styles) => (
|
||||
<div
|
||||
style={{
|
||||
...styles,
|
||||
position: "absolute",
|
||||
top: "1rem",
|
||||
right: "1rem",
|
||||
zIndex: 100,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
shadow="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
minWidth: "320px",
|
||||
maxWidth: "420px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<EditNoteIcon
|
||||
sx={{
|
||||
fontSize: 24,
|
||||
color: isDirty
|
||||
? "var(--mantine-color-blue-6)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("viewer.formBar.title", "Form Fields")}
|
||||
</Text>
|
||||
{isDirty && (
|
||||
<Badge size="xs" color="blue" variant="light">
|
||||
{t("viewer.formBar.unsavedBadge", "Unsaved")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{isDirty
|
||||
? t(
|
||||
"viewer.formBar.unsavedDesc",
|
||||
"You have unsaved changes",
|
||||
)
|
||||
: t(
|
||||
"viewer.formBar.hasFieldsDesc",
|
||||
"This PDF contains fillable fields",
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<CloseButton
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t("viewer.formBar.dismiss", "Dismiss")}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isDirty && (
|
||||
<Group gap="xs" mt="xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<SaveIcon sx={{ fontSize: 18 }} />}
|
||||
loading={applying}
|
||||
disabled={saving}
|
||||
onClick={handleApply}
|
||||
flex={1}
|
||||
>
|
||||
{t("viewer.formBar.apply", "Apply Changes")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
leftSection={<DownloadIcon sx={{ fontSize: 18 }} />}
|
||||
loading={saving}
|
||||
disabled={applying}
|
||||
onClick={handleDownload}
|
||||
flex={1}
|
||||
>
|
||||
{t("viewer.formBar.download", "Download PDF")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormSaveBar;
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Shared field type metadata: icons and color mappings.
|
||||
* Used by FormFill, FormFieldSidebar, and any future form tools.
|
||||
*/
|
||||
import React from "react";
|
||||
import type { FormFieldType } from "@app/tools/formFill/types";
|
||||
import TextFieldsIcon from "@mui/icons-material/TextFields";
|
||||
import CheckBoxIcon from "@mui/icons-material/CheckBox";
|
||||
import ArrowDropDownCircleIcon from "@mui/icons-material/ArrowDropDownCircle";
|
||||
import RadioButtonCheckedIcon from "@mui/icons-material/RadioButtonChecked";
|
||||
import ListIcon from "@mui/icons-material/List";
|
||||
import DrawIcon from "@mui/icons-material/Draw";
|
||||
|
||||
export const FIELD_TYPE_ICON: Record<FormFieldType, React.ReactNode> = {
|
||||
text: <TextFieldsIcon sx={{ fontSize: "inherit" }} />,
|
||||
checkbox: <CheckBoxIcon sx={{ fontSize: "inherit" }} />,
|
||||
combobox: <ArrowDropDownCircleIcon sx={{ fontSize: "inherit" }} />,
|
||||
listbox: <ListIcon sx={{ fontSize: "inherit" }} />,
|
||||
radio: <RadioButtonCheckedIcon sx={{ fontSize: "inherit" }} />,
|
||||
button: <DrawIcon sx={{ fontSize: "inherit" }} />,
|
||||
signature: <DrawIcon sx={{ fontSize: "inherit" }} />,
|
||||
};
|
||||
|
||||
export const FIELD_TYPE_COLOR: Record<FormFieldType, string> = {
|
||||
text: "blue",
|
||||
checkbox: "green",
|
||||
combobox: "violet",
|
||||
listbox: "cyan",
|
||||
radio: "orange",
|
||||
button: "gray",
|
||||
signature: "pink",
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* API service for form-related backend calls.
|
||||
*/
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import type { FormField } from "@app/tools/formFill/types";
|
||||
|
||||
/**
|
||||
* Fetch form fields with coordinates from the backend.
|
||||
* Calls POST /api/v1/form/fields-with-coordinates
|
||||
*/
|
||||
export async function fetchFormFieldsWithCoordinates(
|
||||
file: File | Blob,
|
||||
): Promise<FormField[]> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await apiClient.post<FormField[]>(
|
||||
"/api/v1/form/fields-with-coordinates",
|
||||
formData,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill form fields and get back a filled PDF blob.
|
||||
* Calls POST /api/v1/form/fill
|
||||
*/
|
||||
export async function fillFormFields(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean = false,
|
||||
): Promise<Blob> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append(
|
||||
"data",
|
||||
new Blob([JSON.stringify(values)], { type: "application/json" }),
|
||||
);
|
||||
formData.append("flatten", String(flatten));
|
||||
|
||||
const response = await apiClient.post("/api/v1/form/fill", formData, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form fields as CSV.
|
||||
* Calls POST /api/v1/form/extract-csv
|
||||
*/
|
||||
export async function extractFormFieldsCsv(
|
||||
file: File | Blob,
|
||||
values?: Record<string, string>,
|
||||
): Promise<Blob> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (values) {
|
||||
formData.append(
|
||||
"data",
|
||||
new Blob([JSON.stringify(values)], { type: "application/json" }),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await apiClient.post("/api/v1/form/extract-csv", formData, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form fields as XLSX.
|
||||
* Calls POST /api/v1/form/extract-xlsx
|
||||
*/
|
||||
export async function extractFormFieldsXlsx(
|
||||
file: File | Blob,
|
||||
values?: Record<string, string>,
|
||||
): Promise<Blob> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (values) {
|
||||
formData.append(
|
||||
"data",
|
||||
new Blob([JSON.stringify(values)], { type: "application/json" }),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await apiClient.post("/api/v1/form/extract-xlsx", formData, {
|
||||
responseType: "blob",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export {
|
||||
FormFillProvider,
|
||||
useFormFill,
|
||||
useFieldValue,
|
||||
useAllFormValues,
|
||||
} from "@app/tools/formFill/FormFillContext";
|
||||
export { FormFieldSidebar } from "@app/tools/formFill/FormFieldSidebar";
|
||||
export { FormFieldOverlay } from "@app/tools/formFill/FormFieldOverlay";
|
||||
export { FormSaveBar } from "@app/tools/formFill/FormSaveBar";
|
||||
export { default as FormFill } from "@app/tools/formFill/FormFill";
|
||||
export { FieldInput } from "@app/tools/formFill/FieldInput";
|
||||
export {
|
||||
FIELD_TYPE_ICON,
|
||||
FIELD_TYPE_COLOR,
|
||||
} from "@app/tools/formFill/fieldMeta";
|
||||
export type {
|
||||
FormField,
|
||||
FormFieldType,
|
||||
FormFillState,
|
||||
WidgetCoordinates,
|
||||
} from "@app/tools/formFill/types";
|
||||
export type { IFormDataProvider } from "@app/tools/formFill/providers/types";
|
||||
export { PdfiumFormProvider } from "@app/tools/formFill/providers/PdfiumFormProvider";
|
||||
export { PdfBoxFormProvider } from "@app/tools/formFill/providers/PdfBoxFormProvider";
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* PdfBoxFormProvider — Backend API form data provider using PDFBox.
|
||||
*
|
||||
* Delegates form field extraction and filling to the server-side Java
|
||||
* implementation via REST endpoints. This provides full-fidelity form
|
||||
* handling including complex field types, appearance generation, and
|
||||
* proper CJK font support.
|
||||
*
|
||||
* Used in the dedicated formFill tool mode.
|
||||
*/
|
||||
import type { FormField } from "@app/tools/formFill/types";
|
||||
import type { IFormDataProvider } from "@app/tools/formFill/providers/types";
|
||||
import {
|
||||
fetchFormFieldsWithCoordinates,
|
||||
fillFormFields,
|
||||
} from "@app/tools/formFill/formApi";
|
||||
|
||||
export class PdfBoxFormProvider implements IFormDataProvider {
|
||||
readonly name = "pdfbox";
|
||||
|
||||
async fetchFields(file: File | Blob): Promise<FormField[]> {
|
||||
return fetchFormFieldsWithCoordinates(file);
|
||||
}
|
||||
|
||||
async fillForm(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob> {
|
||||
return fillFormFields(file, values, flatten);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
/**
|
||||
* PdfiumFormProvider Frontend-only form data provider using PDFium WASM.
|
||||
*
|
||||
* Replaces the old pdf-lib based PdfLibFormProvider. Extracts form fields
|
||||
* directly from the PDF byte stream via @embedpdf/pdfium WASM and fills
|
||||
* them without any backend calls.
|
||||
*
|
||||
* Used in normal viewer mode when the user views a PDF with form fields.
|
||||
*
|
||||
* Coordinate system:
|
||||
* PDFium provides widget rectangles in PDF user space (lower-left origin).
|
||||
* We transform them to CSS space (top-left origin) matching what the backend
|
||||
* FormUtils.createWidgetCoordinates() does, so the same overlay code works
|
||||
* for both providers.
|
||||
*/
|
||||
import { PDF_FORM_FIELD_TYPE } from "@app/services/pdfiumService";
|
||||
import { FPDF_ANNOT_WIDGET, FLAT_PRINT } from "@app/utils/pdfiumBitmapUtils";
|
||||
import type {
|
||||
FormField,
|
||||
FormFieldType,
|
||||
WidgetCoordinates,
|
||||
ButtonAction,
|
||||
} from "@app/tools/formFill/types";
|
||||
import type { IFormDataProvider } from "@app/tools/formFill/providers/types";
|
||||
import {
|
||||
closeDocAndFreeBuffer,
|
||||
extractFormFields,
|
||||
getPdfiumModule,
|
||||
openRawDocumentSafe,
|
||||
readUtf16,
|
||||
saveRawDocument,
|
||||
type PdfiumFormField,
|
||||
} from "@app/services/pdfiumService";
|
||||
|
||||
/**
|
||||
* Map PDFium form field type enum to our FormFieldType string.
|
||||
*/
|
||||
function mapFieldType(t: PDF_FORM_FIELD_TYPE): FormFieldType {
|
||||
switch (t) {
|
||||
case PDF_FORM_FIELD_TYPE.TEXTFIELD:
|
||||
return "text";
|
||||
case PDF_FORM_FIELD_TYPE.CHECKBOX:
|
||||
return "checkbox";
|
||||
case PDF_FORM_FIELD_TYPE.COMBOBOX:
|
||||
return "combobox";
|
||||
case PDF_FORM_FIELD_TYPE.RADIOBUTTON:
|
||||
return "radio";
|
||||
case PDF_FORM_FIELD_TYPE.LISTBOX:
|
||||
return "listbox";
|
||||
case PDF_FORM_FIELD_TYPE.PUSHBUTTON:
|
||||
return "button";
|
||||
case PDF_FORM_FIELD_TYPE.SIGNATURE:
|
||||
return "signature";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PdfiumFormField (from pdfiumService) to the UI FormField shape.
|
||||
* @param optInfo When provided, overrides options/displayOptions for combo/listbox fields.
|
||||
* @param buttonInfo When provided, sets buttonLabel and buttonAction for push buttons.
|
||||
*/
|
||||
function toFormField(
|
||||
f: PdfiumFormField & { _tooltip?: string | null },
|
||||
optInfo?: { exportValues: string[]; displayValues: string[] } | null,
|
||||
buttonInfo?: { label?: string; action?: ButtonAction } | null,
|
||||
): FormField {
|
||||
const type = mapFieldType(f.type);
|
||||
const optionLabels = f.options.map((o) => o.label);
|
||||
|
||||
// Build WidgetCoordinates from the PDFium widget rects
|
||||
const widgets: WidgetCoordinates[] = f.widgets.map((w) => ({
|
||||
pageIndex: w.pageIndex,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
width: w.width,
|
||||
height: w.height,
|
||||
exportValue: w.exportValue,
|
||||
fontSize: w.fontSize,
|
||||
}));
|
||||
|
||||
// Derive value string
|
||||
let value = f.value;
|
||||
if (type === "checkbox") {
|
||||
value = f.isChecked ? "Yes" : "Off";
|
||||
} else if (type === "radio") {
|
||||
// Use widget index as the canonical radio value.
|
||||
// This avoids issues with duplicate exportValues across widgets
|
||||
// (e.g., all widgets having exportValue "Yes").
|
||||
value = "";
|
||||
for (let i = 0; i < f.widgets.length; i++) {
|
||||
if (f.widgets[i].isChecked) {
|
||||
value = String(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use pdf-lib extracted export/display values when available
|
||||
let options: string[] | null = optionLabels.length > 0 ? optionLabels : null;
|
||||
let displayOptions: string[] | null = null;
|
||||
if (optInfo && optInfo.exportValues.length > 0) {
|
||||
options = optInfo.exportValues;
|
||||
displayOptions = optInfo.displayValues;
|
||||
}
|
||||
|
||||
return {
|
||||
name: f.name,
|
||||
label: f.name.split(".").pop() || f.name,
|
||||
type,
|
||||
value,
|
||||
options,
|
||||
displayOptions,
|
||||
required: f.isRequired,
|
||||
readOnly: f.isReadOnly,
|
||||
multiSelect: f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
|
||||
multiline: type === "text" && (f.flags & 0x1000) !== 0, // bit 13 = Multiline
|
||||
tooltip: f._tooltip ?? null,
|
||||
widgets,
|
||||
buttonLabel: buttonInfo?.label ?? null,
|
||||
buttonAction: buttonInfo?.action ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PdfLibFormProvider — now backed by PDFium WASM.
|
||||
*
|
||||
* The class name is kept for backwards-compatibility with existing imports.
|
||||
* Internally everything goes through @embedpdf/pdfium.
|
||||
*/
|
||||
export class PdfiumFormProvider implements IFormDataProvider {
|
||||
/** Provider identifier — kept as 'pdf-lib' for backwards-compatibility. */
|
||||
readonly name = "pdf-lib";
|
||||
|
||||
async fetchFields(file: File | Blob): Promise<FormField[]> {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdfiumFields = await extractFormFields(arrayBuffer);
|
||||
|
||||
// Enrich with alternate names (tooltips)
|
||||
await this.enrichWithAlternateNames(arrayBuffer, pdfiumFields);
|
||||
|
||||
// Enrich combo/listbox fields with export/display values from pdf-lib
|
||||
const optMap = await this.extractDisplayOptions(
|
||||
arrayBuffer,
|
||||
pdfiumFields,
|
||||
);
|
||||
|
||||
// Enrich push buttons with label (/MK/CA) and action (/A) from pdf-lib
|
||||
const buttonInfoMap = await this.extractButtonInfo(
|
||||
arrayBuffer,
|
||||
pdfiumFields,
|
||||
);
|
||||
|
||||
return pdfiumFields
|
||||
.filter((f) => f.widgets.length > 0)
|
||||
.map((f) =>
|
||||
toFormField(
|
||||
f,
|
||||
optMap.get(f.name) ?? null,
|
||||
buttonInfoMap.get(f.name) ?? null,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("[PdfiumFormProvider] Failed to extract form fields:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich fields with alternate names (tooltip / TU entry) via PDFium.
|
||||
*/
|
||||
private async enrichWithAlternateNames(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(data);
|
||||
try {
|
||||
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
|
||||
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(
|
||||
docPtr,
|
||||
formInfoPtr,
|
||||
);
|
||||
if (!formEnvPtr) return;
|
||||
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
const nameToField = new Map(fields.map((f) => [f.name, f]));
|
||||
const enriched = new Set<string>();
|
||||
|
||||
for (
|
||||
let pageIdx = 0;
|
||||
pageIdx < pageCount && enriched.size < nameToField.size;
|
||||
pageIdx++
|
||||
) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
|
||||
if (!pagePtr) continue;
|
||||
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
|
||||
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
for (
|
||||
let ai = 0;
|
||||
ai < annotCount && enriched.size < nameToField.size;
|
||||
ai++
|
||||
) {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
|
||||
if (!annotPtr) continue;
|
||||
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let name = "";
|
||||
if (nl > 0) {
|
||||
const nb = m.pdfium.wasmExports.malloc(nl);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
|
||||
name = readUtf16(m, nb, nl);
|
||||
m.pdfium.wasmExports.free(nb);
|
||||
}
|
||||
|
||||
if (name && nameToField.has(name) && !enriched.has(name)) {
|
||||
const altLen = m.FPDFAnnot_GetFormFieldAlternateName(
|
||||
formEnvPtr,
|
||||
annotPtr,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
if (altLen > 0) {
|
||||
const altBuf = m.pdfium.wasmExports.malloc(altLen);
|
||||
m.FPDFAnnot_GetFormFieldAlternateName(
|
||||
formEnvPtr,
|
||||
annotPtr,
|
||||
altBuf,
|
||||
altLen,
|
||||
);
|
||||
const altName = readUtf16(m, altBuf, altLen);
|
||||
m.pdfium.wasmExports.free(altBuf);
|
||||
(nameToField.get(name) as any)._tooltip = altName || null;
|
||||
}
|
||||
enriched.add(name);
|
||||
}
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
|
||||
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
|
||||
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
|
||||
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[PdfiumFormProvider] Failed to enrich alternate names:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pdf-lib to read /Opt arrays for combo/listbox fields.
|
||||
* Returns a map of fieldName → { exportValues, displayValues }.
|
||||
* PDFium only exposes display labels; pdf-lib can read the raw /Opt entries
|
||||
* to separate [export, display] pairs.
|
||||
*/
|
||||
private async extractDisplayOptions(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<Map<string, { exportValues: string[]; displayValues: string[] }>> {
|
||||
const result = new Map<
|
||||
string,
|
||||
{ exportValues: string[]; displayValues: string[] }
|
||||
>();
|
||||
const comboOrList = fields.filter(
|
||||
(f) =>
|
||||
f.type === PDF_FORM_FIELD_TYPE.COMBOBOX ||
|
||||
f.type === PDF_FORM_FIELD_TYPE.LISTBOX,
|
||||
);
|
||||
if (comboOrList.length === 0) return result;
|
||||
|
||||
try {
|
||||
const {
|
||||
PDFDocument,
|
||||
PDFName,
|
||||
PDFArray,
|
||||
PDFString,
|
||||
PDFHexString,
|
||||
PDFDropdown,
|
||||
PDFOptionList,
|
||||
} = await import("@cantoo/pdf-lib");
|
||||
const doc = await PDFDocument.load(data, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
const form = doc.getForm();
|
||||
|
||||
const decodeText = (obj: unknown): string => {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString)
|
||||
return obj.decodeText();
|
||||
return String(obj ?? "");
|
||||
};
|
||||
|
||||
for (const pf of comboOrList) {
|
||||
try {
|
||||
const field = form.getField(pf.name);
|
||||
if (
|
||||
!(field instanceof PDFDropdown) &&
|
||||
!(field instanceof PDFOptionList)
|
||||
)
|
||||
continue;
|
||||
|
||||
const acroDict = (field.acroField as any).dict;
|
||||
const optRaw = acroDict.lookup(PDFName.of("Opt"));
|
||||
if (!(optRaw instanceof PDFArray)) continue;
|
||||
|
||||
const exportValues: string[] = [];
|
||||
const displayValues: string[] = [];
|
||||
let hasDifference = false;
|
||||
|
||||
for (let i = 0; i < optRaw.size(); i++) {
|
||||
try {
|
||||
const entry = optRaw.lookup(i);
|
||||
if (entry instanceof PDFArray && entry.size() >= 2) {
|
||||
const exp = decodeText(entry.lookup(0));
|
||||
const disp = decodeText(entry.lookup(1));
|
||||
exportValues.push(exp);
|
||||
displayValues.push(disp);
|
||||
if (exp !== disp) hasDifference = true;
|
||||
} else {
|
||||
const val = decodeText(entry);
|
||||
exportValues.push(val);
|
||||
displayValues.push(val);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (exportValues.length > 0) {
|
||||
result.set(pf.name, {
|
||||
exportValues,
|
||||
displayValues: hasDifference ? displayValues : exportValues,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Skip individual field errors
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[PdfiumFormProvider] Failed to extract display options:",
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use pdf-lib to extract push button labels (/MK/CA) and actions (/A) for each button field.
|
||||
* Returns a map of fieldName → { label?, action? }.
|
||||
*/
|
||||
private async extractButtonInfo(
|
||||
data: ArrayBuffer,
|
||||
fields: PdfiumFormField[],
|
||||
): Promise<Map<string, { label?: string; action?: ButtonAction }>> {
|
||||
const result = new Map<string, { label?: string; action?: ButtonAction }>();
|
||||
const buttons = fields.filter(
|
||||
(f) => f.type === PDF_FORM_FIELD_TYPE.PUSHBUTTON,
|
||||
);
|
||||
if (buttons.length === 0) return result;
|
||||
|
||||
try {
|
||||
const { PDFDocument, PDFName, PDFString, PDFHexString, PDFDict } =
|
||||
await import("@cantoo/pdf-lib");
|
||||
|
||||
const doc = await PDFDocument.load(data, {
|
||||
ignoreEncryption: true,
|
||||
throwOnInvalidObject: false,
|
||||
});
|
||||
const form = doc.getForm();
|
||||
|
||||
const decodeText = (obj: unknown): string | null => {
|
||||
if (obj instanceof PDFString || obj instanceof PDFHexString)
|
||||
return obj.decodeText();
|
||||
if (obj instanceof PDFName)
|
||||
return (obj as any).asString?.() ?? obj.toString().replace(/^\//, "");
|
||||
return null;
|
||||
};
|
||||
|
||||
const parseActionDict = (aObj: unknown): ButtonAction | null => {
|
||||
if (!(aObj instanceof PDFDict)) return null;
|
||||
const sObj = aObj.lookup(PDFName.of("S"));
|
||||
if (!(sObj instanceof PDFName)) return null;
|
||||
const actionType: string =
|
||||
(sObj as any).asString?.() ?? sObj.toString().replace(/^\//, "");
|
||||
|
||||
switch (actionType) {
|
||||
case "Named": {
|
||||
const nObj = aObj.lookup(PDFName.of("N"));
|
||||
const name =
|
||||
nObj instanceof PDFName
|
||||
? ((nObj as any).asString?.() ??
|
||||
nObj.toString().replace(/^\//, ""))
|
||||
: "";
|
||||
return { type: "named", namedAction: name };
|
||||
}
|
||||
case "JavaScript": {
|
||||
const jsObj = aObj.lookup(PDFName.of("JS"));
|
||||
const js = decodeText(jsObj) ?? jsObj?.toString() ?? "";
|
||||
return { type: "javascript", javascript: js };
|
||||
}
|
||||
case "SubmitForm": {
|
||||
const fObj = aObj.lookup(PDFName.of("F"));
|
||||
let url = "";
|
||||
if (fObj instanceof PDFDict) {
|
||||
url = decodeText(fObj.lookup(PDFName.of("F"))) ?? "";
|
||||
} else if (fObj) {
|
||||
url = decodeText(fObj) ?? fObj.toString();
|
||||
}
|
||||
const flagsObj = aObj.lookup(PDFName.of("Flags"));
|
||||
const flags =
|
||||
typeof (flagsObj as any)?.asNumber === "function"
|
||||
? (flagsObj as any).asNumber()
|
||||
: 0;
|
||||
return { type: "submitForm", url, submitFlags: flags };
|
||||
}
|
||||
case "ResetForm":
|
||||
return { type: "resetForm" };
|
||||
case "URI": {
|
||||
const uriObj = aObj.lookup(PDFName.of("URI"));
|
||||
return { type: "uri", url: decodeText(uriObj) ?? "" };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getMkCaption = (dict: any): string | null => {
|
||||
try {
|
||||
const mkObj = dict.lookup(PDFName.of("MK"));
|
||||
if (!(mkObj instanceof PDFDict)) return null;
|
||||
const caObj = mkObj.lookup(PDFName.of("CA"));
|
||||
return decodeText(caObj);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionFromDict = (dict: any): ButtonAction | null => {
|
||||
try {
|
||||
return parseActionDict(dict.lookup(PDFName.of("A")));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buttonNames = new Set(buttons.map((b) => b.name));
|
||||
|
||||
for (const field of form.getFields()) {
|
||||
const name = field.getName();
|
||||
if (!buttonNames.has(name)) continue;
|
||||
|
||||
try {
|
||||
const acroField = (field as any).acroField;
|
||||
if (!acroField?.dict) continue;
|
||||
|
||||
const info: { label?: string; action?: ButtonAction } = {};
|
||||
|
||||
// Try widget dicts first (each widget can have its own /MK and /A)
|
||||
const widgets: any[] = (acroField as any).getWidgets?.() ?? [];
|
||||
for (const widget of widgets) {
|
||||
if (!info.label) {
|
||||
const label = getMkCaption(widget.dict);
|
||||
if (label) info.label = label;
|
||||
}
|
||||
if (!info.action) {
|
||||
const action = getActionFromDict(widget.dict);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
if (info.label && info.action) break;
|
||||
}
|
||||
|
||||
// Fall back to field-level dict
|
||||
if (!info.label) {
|
||||
const label = getMkCaption(acroField.dict);
|
||||
if (label) info.label = label;
|
||||
}
|
||||
if (!info.action) {
|
||||
const action = getActionFromDict(acroField.dict);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
|
||||
// Also check /AA (Additional Actions) → /U (Mouse Up) if no /A found
|
||||
if (!info.action) {
|
||||
try {
|
||||
const aaObj = acroField.dict.lookup(PDFName.of("AA"));
|
||||
if (aaObj instanceof PDFDict) {
|
||||
const uObj = aaObj.lookup(PDFName.of("U"));
|
||||
const action = parseActionDict(uObj);
|
||||
if (action) info.action = action;
|
||||
}
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
}
|
||||
|
||||
if (info.label || info.action) {
|
||||
result.set(name, info);
|
||||
}
|
||||
} catch {
|
||||
/* skip individual field errors */
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[PdfiumFormProvider] Failed to extract button info:", e);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async fillForm(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const m = await getPdfiumModule();
|
||||
const docPtr = await openRawDocumentSafe(arrayBuffer);
|
||||
|
||||
try {
|
||||
const formInfoPtr = m.PDFiumExt_OpenFormFillInfo();
|
||||
const formEnvPtr = m.PDFiumExt_InitFormFillEnvironment(
|
||||
docPtr,
|
||||
formInfoPtr,
|
||||
);
|
||||
if (!formEnvPtr) {
|
||||
throw new Error("PDFium: failed to initialise form environment");
|
||||
}
|
||||
|
||||
const pageCount = m.FPDF_GetPageCount(docPtr);
|
||||
|
||||
// Track radio widget index per field for index-based matching.
|
||||
// The UI stores radio values as widget indices (e.g., "0", "1", "2").
|
||||
const radioWidgetIdx = new Map<string, number>();
|
||||
|
||||
for (let pageIdx = 0; pageIdx < pageCount; pageIdx++) {
|
||||
const pagePtr = m.FPDF_LoadPage(docPtr, pageIdx);
|
||||
if (!pagePtr) continue;
|
||||
m.FORM_OnAfterLoadPage(pagePtr, formEnvPtr);
|
||||
|
||||
const annotCount = m.FPDFPage_GetAnnotCount(pagePtr);
|
||||
for (let ai = 0; ai < annotCount; ai++) {
|
||||
const annotPtr = m.FPDFPage_GetAnnot(pagePtr, ai);
|
||||
if (!annotPtr) continue;
|
||||
if (m.FPDFAnnot_GetSubtype(annotPtr) !== FPDF_ANNOT_WIDGET) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nl = m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, 0, 0);
|
||||
let fieldName = "";
|
||||
if (nl > 0) {
|
||||
const nb = m.pdfium.wasmExports.malloc(nl);
|
||||
m.FPDFAnnot_GetFormFieldName(formEnvPtr, annotPtr, nb, nl);
|
||||
fieldName = readUtf16(m, nb, nl);
|
||||
m.pdfium.wasmExports.free(nb);
|
||||
}
|
||||
|
||||
if (!fieldName || !(fieldName in values)) {
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = values[fieldName];
|
||||
const fieldType = m.FPDFAnnot_GetFormFieldType(formEnvPtr, annotPtr);
|
||||
|
||||
try {
|
||||
if (fieldType === PDF_FORM_FIELD_TYPE.TEXTFIELD) {
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_SelectAllText(formEnvPtr, pagePtr);
|
||||
const wPtr = m.pdfium.wasmExports.malloc((value.length + 1) * 2);
|
||||
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
|
||||
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
|
||||
m.pdfium.wasmExports.free(wPtr);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
} else if (fieldType === PDF_FORM_FIELD_TYPE.CHECKBOX) {
|
||||
// Toggle checkbox using the same approach as @embedpdf engine:
|
||||
// Focus → Enter key (FORM_OnChar with keycode 13) → Kill focus.
|
||||
// Click simulation (FORM_OnLButtonDown/Up) does NOT reliably
|
||||
// persist checkbox state changes in headless/offscreen mode.
|
||||
const isCurrentlyChecked = m.FPDFAnnot_IsChecked(
|
||||
formEnvPtr,
|
||||
annotPtr,
|
||||
);
|
||||
const shouldBeChecked = value !== "" && value !== "Off";
|
||||
if (isCurrentlyChecked !== shouldBeChecked) {
|
||||
const ENTER_KEY = 13;
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
} else if (fieldType === PDF_FORM_FIELD_TYPE.RADIOBUTTON) {
|
||||
// Radio values are stored as widget indices (e.g., "0", "1", "2").
|
||||
// Track the current widget index for this field and toggle only
|
||||
// the widget whose index matches the stored value.
|
||||
const currentIdx = radioWidgetIdx.get(fieldName) ?? 0;
|
||||
radioWidgetIdx.set(fieldName, currentIdx + 1);
|
||||
|
||||
const targetIdx = parseInt(value, 10);
|
||||
if (!isNaN(targetIdx) && currentIdx === targetIdx) {
|
||||
const isAlreadyChecked = m.FPDFAnnot_IsChecked(
|
||||
formEnvPtr,
|
||||
annotPtr,
|
||||
);
|
||||
if (!isAlreadyChecked) {
|
||||
const ENTER_KEY = 13;
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
m.FORM_OnChar(formEnvPtr, pagePtr, ENTER_KEY, 0);
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
fieldType === PDF_FORM_FIELD_TYPE.COMBOBOX ||
|
||||
fieldType === PDF_FORM_FIELD_TYPE.LISTBOX
|
||||
) {
|
||||
// FORM_SetIndexSelected requires the annotation to be focused first.
|
||||
m.FORM_SetFocusedAnnot(formEnvPtr, annotPtr);
|
||||
|
||||
let matched = false;
|
||||
const optCount = m.FPDFAnnot_GetOptionCount(formEnvPtr, annotPtr);
|
||||
for (let oi = 0; oi < optCount; oi++) {
|
||||
const optLen = m.FPDFAnnot_GetOptionLabel(
|
||||
formEnvPtr,
|
||||
annotPtr,
|
||||
oi,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
if (optLen > 0) {
|
||||
const ob = m.pdfium.wasmExports.malloc(optLen);
|
||||
m.FPDFAnnot_GetOptionLabel(
|
||||
formEnvPtr,
|
||||
annotPtr,
|
||||
oi,
|
||||
ob,
|
||||
optLen,
|
||||
);
|
||||
const optLabel = readUtf16(m, ob, optLen);
|
||||
m.pdfium.wasmExports.free(ob);
|
||||
if (optLabel === value) {
|
||||
m.FORM_SetIndexSelected(formEnvPtr, pagePtr, oi, true);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: set as text (handles editable combos or
|
||||
// cases where export values differ from display labels).
|
||||
if (!matched && value) {
|
||||
m.FORM_SelectAllText(formEnvPtr, pagePtr);
|
||||
const wPtr = m.pdfium.wasmExports.malloc(
|
||||
(value.length + 1) * 2,
|
||||
);
|
||||
m.pdfium.stringToUTF16(value, wPtr, (value.length + 1) * 2);
|
||||
m.FORM_ReplaceSelection(formEnvPtr, pagePtr, wPtr);
|
||||
m.pdfium.wasmExports.free(wPtr);
|
||||
}
|
||||
|
||||
m.FORM_ForceToKillFocus(formEnvPtr);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[PdfiumFormProvider] Failed to set "${fieldName}":`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
m.FPDFPage_CloseAnnot(annotPtr);
|
||||
}
|
||||
|
||||
if (flatten) {
|
||||
m.FPDFPage_Flatten(pagePtr, FLAT_PRINT);
|
||||
}
|
||||
|
||||
m.FORM_OnBeforeClosePage(pagePtr, formEnvPtr);
|
||||
m.FPDF_ClosePage(pagePtr);
|
||||
}
|
||||
|
||||
m.PDFiumExt_ExitFormFillEnvironment(formEnvPtr);
|
||||
m.PDFiumExt_CloseFormFillInfo(formInfoPtr);
|
||||
|
||||
const savedBytes = await saveRawDocument(docPtr);
|
||||
return new Blob([savedBytes], { type: "application/pdf" });
|
||||
} finally {
|
||||
closeDocAndFreeBuffer(m, docPtr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { IFormDataProvider } from "@app/tools/formFill/providers/types";
|
||||
export { PdfiumFormProvider } from "@app/tools/formFill/providers/PdfiumFormProvider";
|
||||
export { PdfBoxFormProvider } from "@app/tools/formFill/providers/PdfBoxFormProvider";
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* IFormDataProvider — Common interface for form data providers.
|
||||
*
|
||||
* This abstraction allows the form fill UI to work with different backends:
|
||||
* - PdfLibFormProvider: Frontend-only, uses pdf-lib to extract/fill form fields
|
||||
* (used in normal viewer mode to avoid sending large PDFs to the backend)
|
||||
* - PdfBoxFormProvider: Backend API, uses PDFBox via REST endpoints
|
||||
* (used in the dedicated formFill tool for full-fidelity form handling)
|
||||
*
|
||||
* The UI components (FormFieldOverlay, FormFill, FormFieldSidebar) consume
|
||||
* data through FormFillContext, which delegates to whichever provider is active.
|
||||
*/
|
||||
import type { FormField } from "@app/tools/formFill/types";
|
||||
|
||||
export interface IFormDataProvider {
|
||||
/** Unique identifier for the provider (for debugging/logging) */
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Extract form fields with their coordinates from a PDF file.
|
||||
* Returns the same FormField[] shape regardless of provider.
|
||||
*/
|
||||
fetchFields(file: File | Blob): Promise<FormField[]>;
|
||||
|
||||
/**
|
||||
* Apply filled values to a PDF and return the resulting PDF blob.
|
||||
* @param file - The original PDF
|
||||
* @param values - Map of field name → value
|
||||
* @param flatten - Whether to flatten the form (make fields non-editable)
|
||||
* @returns The filled PDF as a Blob
|
||||
*/
|
||||
fillForm(
|
||||
file: File | Blob,
|
||||
values: Record<string, string>,
|
||||
flatten: boolean,
|
||||
): Promise<Blob>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Types for the Form Fill PDF Viewer feature.
|
||||
* These mirror the backend FormFieldWithCoordinates model.
|
||||
*/
|
||||
|
||||
export interface WidgetCoordinates {
|
||||
pageIndex: number;
|
||||
x: number; // PDF points, un-rotated, CSS upper-left origin
|
||||
y: number; // PDF points, un-rotated, CSS upper-left origin
|
||||
width: number; // PDF points
|
||||
height: number; // PDF points
|
||||
/** Export value for this specific widget (radio/checkbox only) */
|
||||
exportValue?: string;
|
||||
/** Font size in PDF points */
|
||||
fontSize?: number;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: FormFieldType;
|
||||
value: string;
|
||||
/** Export values used for data binding (sent to backend) */
|
||||
options: string[] | null;
|
||||
/** Human-readable display labels parallel to options. Null when same as options. */
|
||||
displayOptions: string[] | null;
|
||||
required: boolean;
|
||||
readOnly: boolean;
|
||||
multiSelect: boolean;
|
||||
multiline: boolean;
|
||||
tooltip: string | null;
|
||||
widgets: WidgetCoordinates[] | null;
|
||||
/** Visual label for push buttons (from /MK/CA in the PDF appearance dict) */
|
||||
buttonLabel?: string | null;
|
||||
/** Action descriptor for push buttons */
|
||||
buttonAction?: ButtonAction | null;
|
||||
/** Pre-rendered appearance image for signed signature fields (data URL). */
|
||||
appearanceDataUrl?: string;
|
||||
}
|
||||
|
||||
export type FormFieldType =
|
||||
| "text"
|
||||
| "checkbox"
|
||||
| "combobox"
|
||||
| "listbox"
|
||||
| "radio"
|
||||
| "button"
|
||||
| "signature";
|
||||
|
||||
export type ButtonActionType =
|
||||
| "named"
|
||||
| "javascript"
|
||||
| "submitForm"
|
||||
| "resetForm"
|
||||
| "uri"
|
||||
| "none";
|
||||
|
||||
export interface ButtonAction {
|
||||
type: ButtonActionType;
|
||||
/** For 'named' actions: the PDF action name (e.g. 'Print', 'NextPage', 'PrevPage') */
|
||||
namedAction?: string;
|
||||
/** For 'javascript' actions: the JavaScript source code */
|
||||
javascript?: string;
|
||||
/** For 'submitForm' / 'uri' actions: the target URL */
|
||||
url?: string;
|
||||
/** For 'submitForm' actions: submit flags bitmask */
|
||||
submitFlags?: number;
|
||||
}
|
||||
|
||||
export interface FormFillState {
|
||||
/** Fields fetched from backend with coordinates */
|
||||
fields: FormField[];
|
||||
/** Current user-entered values keyed by field name */
|
||||
values: Record<string, string>;
|
||||
/** Whether a backend fetch is in progress */
|
||||
loading: boolean;
|
||||
/** Error message from fetch */
|
||||
error: string | null;
|
||||
/** Currently focused/selected field name */
|
||||
activeFieldName: string | null;
|
||||
/** Whether the form has been modified */
|
||||
isDirty: boolean;
|
||||
/** Current validation errors keyed by field name */
|
||||
validationErrors: Record<string, string>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,522 @@
|
||||
import {
|
||||
PdfJsonDocument,
|
||||
PdfJsonFont,
|
||||
} from "@app/tools/pdfTextEditor/pdfTextEditorTypes";
|
||||
|
||||
export type FontStatus =
|
||||
| "perfect"
|
||||
| "embedded-subset"
|
||||
| "system-fallback"
|
||||
| "missing"
|
||||
| "unknown";
|
||||
|
||||
export interface FontAnalysis {
|
||||
fontId: string;
|
||||
baseName: string;
|
||||
status: FontStatus;
|
||||
embedded: boolean;
|
||||
isSubset: boolean;
|
||||
isStandard14: boolean;
|
||||
hasWebFormat: boolean;
|
||||
webFormat?: string;
|
||||
subtype?: string;
|
||||
encoding?: string;
|
||||
warnings: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface DocumentFontAnalysis {
|
||||
fonts: FontAnalysis[];
|
||||
canReproducePerfectly: boolean;
|
||||
hasWarnings: boolean;
|
||||
summary: {
|
||||
perfect: number;
|
||||
embeddedSubset: number;
|
||||
systemFallback: number;
|
||||
missing: number;
|
||||
unknown: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a font name indicates it's a subset font.
|
||||
* Subset fonts typically have a 6-character prefix like "ABCDEE+"
|
||||
*/
|
||||
const isSubsetFont = (baseName: string | null | undefined): boolean => {
|
||||
if (!baseName) return false;
|
||||
// Check for common subset patterns: ABCDEF+FontName
|
||||
return /^[A-Z]{6}\+/.test(baseName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font is one of the standard 14 PDF fonts that are guaranteed
|
||||
* to be available on all PDF readers
|
||||
*/
|
||||
const isStandard14Font = (font: PdfJsonFont): boolean => {
|
||||
if (font.standard14Name) return true;
|
||||
|
||||
const baseName = (font.baseName || "").toLowerCase().replace(/[-_\s]/g, "");
|
||||
|
||||
const standard14Patterns = [
|
||||
"timesroman",
|
||||
"timesbold",
|
||||
"timesitalic",
|
||||
"timesbolditalic",
|
||||
"helvetica",
|
||||
"helveticabold",
|
||||
"helveticaoblique",
|
||||
"helveticaboldoblique",
|
||||
"courier",
|
||||
"courierbold",
|
||||
"courieroblique",
|
||||
"courierboldoblique",
|
||||
"symbol",
|
||||
"zapfdingbats",
|
||||
];
|
||||
|
||||
// Check exact matches or if the base name contains the pattern
|
||||
return standard14Patterns.some((pattern) => {
|
||||
// Exact match
|
||||
if (baseName === pattern) return true;
|
||||
// Contains pattern (e.g., "ABCDEF+Helvetica" matches "helvetica")
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font has a fallback available on the backend.
|
||||
* These fonts are embedded in the Stirling PDF backend and can be used
|
||||
* for PDF export even if not in the original PDF.
|
||||
*
|
||||
* Based on PdfJsonFallbackFontService.java
|
||||
*/
|
||||
const hasBackendFallbackFont = (font: PdfJsonFont): boolean => {
|
||||
const baseName = (font.baseName || "").toLowerCase().replace(/[-_\s]/g, "");
|
||||
|
||||
// Backend has these font families available (from PdfJsonFallbackFontService)
|
||||
const backendFonts = [
|
||||
// Liberation fonts (metric-compatible with MS core fonts)
|
||||
"arial",
|
||||
"helvetica",
|
||||
"arimo",
|
||||
"times",
|
||||
"timesnewroman",
|
||||
"tinos",
|
||||
"courier",
|
||||
"couriernew",
|
||||
"cousine",
|
||||
"liberation",
|
||||
"liberationsans",
|
||||
"liberationserif",
|
||||
"liberationmono",
|
||||
// DejaVu fonts
|
||||
"dejavu",
|
||||
"dejavusans",
|
||||
"dejavuserif",
|
||||
"dejavumono",
|
||||
"dejavusansmono",
|
||||
// Noto fonts
|
||||
"noto",
|
||||
"notosans",
|
||||
];
|
||||
|
||||
return backendFonts.some((pattern) => {
|
||||
if (baseName === pattern) return true;
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the base font name from a subset font name
|
||||
* e.g., "ABCDEF+Arial" -> "Arial"
|
||||
*/
|
||||
const extractBaseFontName = (
|
||||
baseName: string | null | undefined,
|
||||
): string | null => {
|
||||
if (!baseName) return null;
|
||||
const match = baseName.match(/^[A-Z]{6}\+(.+)$/);
|
||||
return match ? match[1] : baseName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes a single font to determine if it can be reproduced perfectly
|
||||
* Takes allFonts to check if full versions of subset fonts are available
|
||||
*/
|
||||
export const analyzeFontReproduction = (
|
||||
font: PdfJsonFont,
|
||||
allFonts?: PdfJsonFont[],
|
||||
): FontAnalysis => {
|
||||
const fontId = font.id || font.uid || "unknown";
|
||||
const baseName = font.baseName || "Unknown Font";
|
||||
const isSubset = isSubsetFont(font.baseName);
|
||||
const isStandard14 = isStandard14Font(font);
|
||||
const hasBackendFallback = hasBackendFallbackFont(font);
|
||||
const embedded = font.embedded ?? false;
|
||||
|
||||
// Check available web formats (ordered by preference)
|
||||
const webFormats = [
|
||||
{ key: "webProgram", format: font.webProgramFormat },
|
||||
{ key: "pdfProgram", format: font.pdfProgramFormat },
|
||||
{ key: "program", format: font.programFormat },
|
||||
];
|
||||
|
||||
const availableWebFormat = webFormats.find((f) => f.format);
|
||||
const hasWebFormat = !!availableWebFormat;
|
||||
const webFormat = availableWebFormat?.format || undefined;
|
||||
|
||||
const warnings: string[] = [];
|
||||
const suggestions: string[] = [];
|
||||
let status: FontStatus = "unknown";
|
||||
|
||||
// Check if we have the full font when this is a subset
|
||||
let hasFullFontVersion = false;
|
||||
if (isSubset && allFonts) {
|
||||
const baseFont = extractBaseFontName(font.baseName);
|
||||
if (baseFont) {
|
||||
// Look for a non-subset version of this font with a web format
|
||||
hasFullFontVersion = allFonts.some((f) => {
|
||||
const otherBaseName = extractBaseFontName(f.baseName);
|
||||
const isNotSubset = !isSubsetFont(f.baseName);
|
||||
const hasFormat = !!(
|
||||
f.webProgramFormat ||
|
||||
f.pdfProgramFormat ||
|
||||
f.programFormat
|
||||
);
|
||||
const sameBase =
|
||||
otherBaseName?.toLowerCase() === baseFont.toLowerCase();
|
||||
return sameBase && isNotSubset && hasFormat && (f.embedded ?? false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze font status - focusing on PDF export quality
|
||||
if (isStandard14) {
|
||||
// Standard 14 fonts are always available in PDF readers - perfect for export!
|
||||
status = "perfect";
|
||||
suggestions.push(
|
||||
"Standard PDF font (Times, Helvetica, or Courier). Always available in PDF readers.",
|
||||
);
|
||||
suggestions.push(
|
||||
"Exported PDFs will render consistently across all PDF readers.",
|
||||
);
|
||||
} else if (embedded && !isSubset) {
|
||||
// Perfect: Fully embedded with complete character set
|
||||
status = "perfect";
|
||||
suggestions.push(
|
||||
"Font is fully embedded. Exported PDFs will reproduce text perfectly, even with edits.",
|
||||
);
|
||||
} else if (
|
||||
embedded &&
|
||||
isSubset &&
|
||||
(hasFullFontVersion || hasBackendFallback)
|
||||
) {
|
||||
// Subset but we have the full font or backend fallback - perfect!
|
||||
status = "perfect";
|
||||
if (hasFullFontVersion) {
|
||||
suggestions.push(
|
||||
"Full font version is also available in the document. Exported PDFs can reproduce all characters.",
|
||||
);
|
||||
} else if (hasBackendFallback) {
|
||||
suggestions.push(
|
||||
"Backend has the full font available. Exported PDFs can reproduce all characters, including new text.",
|
||||
);
|
||||
}
|
||||
} else if (embedded && isSubset) {
|
||||
// Good, but subset: May have missing characters if user adds new text
|
||||
status = "embedded-subset";
|
||||
warnings.push(
|
||||
"This is a subset font - only specific characters are embedded in the PDF.",
|
||||
);
|
||||
warnings.push(
|
||||
"Exported PDFs may have missing characters if you add new text with this font.",
|
||||
);
|
||||
suggestions.push(
|
||||
"Existing text will export correctly. New characters may render as boxes (☐) or fallback glyphs.",
|
||||
);
|
||||
} else if (!embedded && hasBackendFallback) {
|
||||
// Not embedded, but backend has it - perfect for export!
|
||||
status = "perfect";
|
||||
suggestions.push(
|
||||
"Backend has this font available. Exported PDFs will use the backend fallback font.",
|
||||
);
|
||||
suggestions.push("Text will export correctly with consistent appearance.");
|
||||
} else if (!embedded) {
|
||||
// Not embedded - must rely on system fonts (risky for export)
|
||||
status = "missing";
|
||||
warnings.push("Font is not embedded in the PDF.");
|
||||
warnings.push(
|
||||
"Exported PDFs will substitute with a fallback font, which may look very different.",
|
||||
);
|
||||
suggestions.push(
|
||||
"Consider re-embedding fonts or accepting that the exported PDF will use fallback fonts.",
|
||||
);
|
||||
} else if (embedded && !hasWebFormat) {
|
||||
// Embedded but no web format available (still okay for export)
|
||||
status = "perfect";
|
||||
suggestions.push(
|
||||
"Font is embedded in the PDF. Exported PDFs will reproduce correctly.",
|
||||
);
|
||||
suggestions.push(
|
||||
"Web preview may use a fallback font, but the final PDF export will be accurate.",
|
||||
);
|
||||
}
|
||||
|
||||
// Additional warnings based on font properties
|
||||
if (font.subtype === "Type0" && font.cidSystemInfo) {
|
||||
const registry = font.cidSystemInfo.registry || "";
|
||||
const ordering = font.cidSystemInfo.ordering || "";
|
||||
if (
|
||||
registry.includes("Adobe") &&
|
||||
(ordering.includes("Identity") || ordering.includes("UCS"))
|
||||
) {
|
||||
// CID fonts with Identity encoding are common for Asian languages
|
||||
if (!embedded || !hasWebFormat) {
|
||||
warnings.push("This CID font may contain Asian or Unicode characters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
font.encoding &&
|
||||
!font.encoding.includes("WinAnsiEncoding") &&
|
||||
!font.encoding.includes("MacRomanEncoding")
|
||||
) {
|
||||
// Custom encodings may cause issues
|
||||
if (font.encoding !== "Identity-H" && font.encoding !== "Identity-V") {
|
||||
warnings.push(`Custom encoding detected: ${font.encoding}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fontId,
|
||||
baseName,
|
||||
status,
|
||||
embedded,
|
||||
isSubset,
|
||||
isStandard14,
|
||||
hasWebFormat,
|
||||
webFormat,
|
||||
subtype: font.subtype || undefined,
|
||||
encoding: font.encoding || undefined,
|
||||
warnings,
|
||||
suggestions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets fonts used on a specific page
|
||||
*/
|
||||
export const getFontsForPage = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex: number,
|
||||
): PdfJsonFont[] => {
|
||||
if (
|
||||
!document?.fonts ||
|
||||
!document?.pages ||
|
||||
pageIndex < 0 ||
|
||||
pageIndex >= document.pages.length
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const page = document.pages[pageIndex];
|
||||
if (!page?.textElements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get unique font IDs used on this page
|
||||
const fontIdsOnPage = new Set<string>();
|
||||
page.textElements.forEach((element) => {
|
||||
if (element?.fontId) {
|
||||
fontIdsOnPage.add(element.fontId);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter fonts to only those used on this page
|
||||
const allFonts = document.fonts.filter(
|
||||
(font): font is PdfJsonFont => font !== null && font !== undefined,
|
||||
);
|
||||
|
||||
const fontsOnPage = allFonts.filter((font) => {
|
||||
// Match by ID
|
||||
if (font.id && fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
// Match by UID
|
||||
if (font.uid && fontIdsOnPage.has(font.uid)) {
|
||||
return true;
|
||||
}
|
||||
// Match by page-specific ID (pageNumber:id format)
|
||||
if (font.pageNumber === pageIndex + 1 && font.id) {
|
||||
const pageSpecificId = `${font.pageNumber}:${font.id}`;
|
||||
if (fontIdsOnPage.has(pageSpecificId) || fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Deduplicate by base font name to avoid showing the same font multiple times
|
||||
const uniqueFonts = new Map<string, PdfJsonFont>();
|
||||
fontsOnPage.forEach((font) => {
|
||||
const baseName =
|
||||
extractBaseFontName(font.baseName) ||
|
||||
font.baseName ||
|
||||
font.id ||
|
||||
"unknown";
|
||||
const key = baseName.toLowerCase();
|
||||
|
||||
// Keep the first occurrence, or prefer non-subset over subset
|
||||
const existing = uniqueFonts.get(key);
|
||||
if (!existing) {
|
||||
uniqueFonts.set(key, font);
|
||||
} else {
|
||||
// Prefer non-subset fonts over subset fonts
|
||||
const existingIsSubset = isSubsetFont(existing.baseName);
|
||||
const currentIsSubset = isSubsetFont(font.baseName);
|
||||
if (existingIsSubset && !currentIsSubset) {
|
||||
uniqueFonts.set(key, font);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueFonts.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes all fonts in a PDF document (or just fonts for a specific page)
|
||||
*/
|
||||
export const analyzeDocumentFonts = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex?: number,
|
||||
): DocumentFontAnalysis => {
|
||||
if (!document?.fonts || document.fonts.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const allFonts = document.fonts.filter(
|
||||
(font): font is PdfJsonFont => font !== null && font !== undefined,
|
||||
);
|
||||
|
||||
// Filter to page-specific fonts if pageIndex is provided
|
||||
const fontsToAnalyze =
|
||||
pageIndex !== undefined ? getFontsForPage(document, pageIndex) : allFonts;
|
||||
|
||||
if (fontsToAnalyze.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fontAnalyses = fontsToAnalyze.map((font) =>
|
||||
analyzeFontReproduction(font, allFonts),
|
||||
);
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
perfect: fontAnalyses.filter((f) => f.status === "perfect").length,
|
||||
embeddedSubset: fontAnalyses.filter((f) => f.status === "embedded-subset")
|
||||
.length,
|
||||
systemFallback: fontAnalyses.filter((f) => f.status === "system-fallback")
|
||||
.length,
|
||||
missing: fontAnalyses.filter((f) => f.status === "missing").length,
|
||||
unknown: fontAnalyses.filter((f) => f.status === "unknown").length,
|
||||
};
|
||||
|
||||
// Can reproduce perfectly ONLY if all fonts are truly perfect (not subsets)
|
||||
const canReproducePerfectly = fontAnalyses.every(
|
||||
(f) => f.status === "perfect",
|
||||
);
|
||||
|
||||
// Has warnings if any font has issues (including subsets)
|
||||
const hasWarnings = fontAnalyses.some(
|
||||
(f) =>
|
||||
f.warnings.length > 0 ||
|
||||
f.status === "missing" ||
|
||||
f.status === "system-fallback" ||
|
||||
f.status === "embedded-subset",
|
||||
);
|
||||
|
||||
return {
|
||||
fonts: fontAnalyses,
|
||||
canReproducePerfectly,
|
||||
hasWarnings,
|
||||
summary,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a human-readable description of the font status
|
||||
*/
|
||||
export const getFontStatusDescription = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case "perfect":
|
||||
return "Fully embedded - perfect reproduction";
|
||||
case "embedded-subset":
|
||||
return "Embedded (subset) - existing text will render correctly";
|
||||
case "system-fallback":
|
||||
return "Using system font - appearance may differ";
|
||||
case "missing":
|
||||
return "Not embedded - will use fallback font";
|
||||
case "unknown":
|
||||
return "Unknown status";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a color indicator for the font status
|
||||
*/
|
||||
export const getFontStatusColor = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case "perfect":
|
||||
return "green";
|
||||
case "embedded-subset":
|
||||
return "blue";
|
||||
case "system-fallback":
|
||||
return "yellow";
|
||||
case "missing":
|
||||
return "red";
|
||||
case "unknown":
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an icon indicator for the font status
|
||||
*/
|
||||
export const getFontStatusIcon = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case "perfect":
|
||||
return "✓";
|
||||
case "embedded-subset":
|
||||
return "⚠";
|
||||
case "system-fallback":
|
||||
return "⚠";
|
||||
case "missing":
|
||||
return "✗";
|
||||
case "unknown":
|
||||
return "?";
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
export interface PdfJsonFontCidSystemInfo {
|
||||
registry?: string | null;
|
||||
ordering?: string | null;
|
||||
supplement?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonTextColor {
|
||||
colorSpace?: string | null;
|
||||
components?: number[] | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonCosValue {
|
||||
type?: string | null;
|
||||
value?: unknown;
|
||||
items?: PdfJsonCosValue[] | null;
|
||||
entries?: Record<string, PdfJsonCosValue | null> | null;
|
||||
stream?: PdfJsonStream | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonFont {
|
||||
id?: string;
|
||||
pageNumber?: number | null;
|
||||
uid?: string | null;
|
||||
baseName?: string | null;
|
||||
subtype?: string | null;
|
||||
encoding?: string | null;
|
||||
cidSystemInfo?: PdfJsonFontCidSystemInfo | null;
|
||||
embedded?: boolean | null;
|
||||
program?: string | null;
|
||||
programFormat?: string | null;
|
||||
webProgram?: string | null;
|
||||
webProgramFormat?: string | null;
|
||||
pdfProgram?: string | null;
|
||||
pdfProgramFormat?: string | null;
|
||||
toUnicode?: string | null;
|
||||
standard14Name?: string | null;
|
||||
fontDescriptorFlags?: number | null;
|
||||
ascent?: number | null;
|
||||
descent?: number | null;
|
||||
capHeight?: number | null;
|
||||
xHeight?: number | null;
|
||||
italicAngle?: number | null;
|
||||
unitsPerEm?: number | null;
|
||||
cosDictionary?: PdfJsonCosValue | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonTextElement {
|
||||
text?: string | null;
|
||||
fontId?: string | null;
|
||||
fontSize?: number | null;
|
||||
fontMatrixSize?: number | null;
|
||||
fontSizeInPt?: number | null;
|
||||
characterSpacing?: number | null;
|
||||
wordSpacing?: number | null;
|
||||
spaceWidth?: number | null;
|
||||
zOrder?: number | null;
|
||||
horizontalScaling?: number | null;
|
||||
leading?: number | null;
|
||||
rise?: number | null;
|
||||
renderingMode?: number | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
textMatrix?: number[] | null;
|
||||
fillColor?: PdfJsonTextColor | null;
|
||||
strokeColor?: PdfJsonTextColor | null;
|
||||
charCodes?: number[] | null;
|
||||
fallbackUsed?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonImageElement {
|
||||
id?: string | null;
|
||||
objectName?: string | null;
|
||||
inlineImage?: boolean | null;
|
||||
nativeWidth?: number | null;
|
||||
nativeHeight?: number | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
left?: number | null;
|
||||
right?: number | null;
|
||||
top?: number | null;
|
||||
bottom?: number | null;
|
||||
transform?: number[] | null;
|
||||
zOrder?: number | null;
|
||||
imageData?: string | null;
|
||||
imageFormat?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonStream {
|
||||
dictionary?: Record<string, unknown> | null;
|
||||
rawData?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonPage {
|
||||
pageNumber?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
rotation?: number | null;
|
||||
mediaBox?: number[] | null;
|
||||
cropBox?: number[] | null;
|
||||
textElements?: PdfJsonTextElement[] | null;
|
||||
imageElements?: PdfJsonImageElement[] | null;
|
||||
resources?: unknown;
|
||||
contentStreams?: PdfJsonStream[] | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonMetadata {
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
subject?: string | null;
|
||||
keywords?: string | null;
|
||||
creator?: string | null;
|
||||
producer?: string | null;
|
||||
creationDate?: string | null;
|
||||
modificationDate?: string | null;
|
||||
trapped?: string | null;
|
||||
numberOfPages?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonDocument {
|
||||
metadata?: PdfJsonMetadata | null;
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pages?: PdfJsonPage[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonPageDimension {
|
||||
pageNumber?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
rotation?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonDocumentMetadata {
|
||||
metadata?: PdfJsonMetadata | null;
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pageDimensions?: PdfJsonPageDimension[] | null;
|
||||
formFields?: unknown[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface TextGroup {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
fontId?: string | null;
|
||||
fontSize?: number | null;
|
||||
fontMatrixSize?: number | null;
|
||||
lineSpacing?: number | null;
|
||||
lineElementCounts?: number[] | null;
|
||||
color?: string | null;
|
||||
fontWeight?: number | "normal" | "bold" | null;
|
||||
rotation?: number | null;
|
||||
anchor?: { x: number; y: number } | null;
|
||||
baselineLength?: number | null;
|
||||
baseline?: number | null;
|
||||
elements: PdfJsonTextElement[];
|
||||
originalElements: PdfJsonTextElement[];
|
||||
text: string;
|
||||
originalText: string;
|
||||
bounds: BoundingBox;
|
||||
childLineGroups?: TextGroup[] | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_WIDTH = 612;
|
||||
export const DEFAULT_PAGE_HEIGHT = 792;
|
||||
|
||||
export interface ConversionProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface PdfTextEditorViewData {
|
||||
document: PdfJsonDocument | null;
|
||||
groupsByPage: TextGroup[][];
|
||||
imagesByPage: PdfJsonImageElement[][];
|
||||
pagePreviews: Map<number, string>;
|
||||
selectedPage: number;
|
||||
dirtyPages: boolean[];
|
||||
hasDocument: boolean;
|
||||
hasVectorPreview: boolean;
|
||||
fileName: string;
|
||||
errorMessage: string | null;
|
||||
isGeneratingPdf: boolean;
|
||||
isConverting: boolean;
|
||||
conversionProgress: ConversionProgress | null;
|
||||
hasChanges: boolean;
|
||||
forceSingleTextElement: boolean;
|
||||
groupingMode: "auto" | "paragraph" | "singleLine";
|
||||
autoScaleText: boolean;
|
||||
onAutoScaleTextChange: (value: boolean) => void;
|
||||
requestPagePreview: (pageIndex: number, scale: number) => void;
|
||||
onSelectPage: (pageIndex: number) => void;
|
||||
onGroupEdit: (pageIndex: number, groupId: string, value: string) => void;
|
||||
onGroupDelete: (pageIndex: number, groupId: string) => void;
|
||||
onImageTransform: (
|
||||
pageIndex: number,
|
||||
imageId: string,
|
||||
next: {
|
||||
left: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
transform: number[];
|
||||
},
|
||||
) => void;
|
||||
onImageReset: (pageIndex: number, imageId: string) => void;
|
||||
onReset: () => void;
|
||||
onDownloadJson: () => void;
|
||||
onGeneratePdf: () => void;
|
||||
onGeneratePdfForNavigation: () => Promise<void>;
|
||||
onSaveToWorkbench: () => Promise<void>;
|
||||
isSavingToWorkbench: boolean;
|
||||
onForceSingleTextElementChange: (value: boolean) => void;
|
||||
onGroupingModeChange: (value: "auto" | "paragraph" | "singleLine") => void;
|
||||
onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean;
|
||||
onUngroupGroup: (pageIndex: number, groupId: string) => boolean;
|
||||
onLoadFile: (file: File) => void;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||
import SignSettings, {
|
||||
SignatureSource,
|
||||
} from "@app/components/tools/sign/SignSettings";
|
||||
import {
|
||||
DEFAULT_PARAMETERS,
|
||||
useSignParameters,
|
||||
SignParameters,
|
||||
} from "@app/hooks/tools/sign/useSignParameters";
|
||||
import { useSignOperation } from "@app/hooks/tools/sign/useSignOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { useNavigation } from "@app/contexts/NavigationContext";
|
||||
import { useSignature } from "@app/contexts/SignatureContext";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { flattenSignatures } from "@app/utils/signatureFlattening";
|
||||
|
||||
export type StampToolConfig = {
|
||||
toolId: ToolId;
|
||||
translationScope?: string;
|
||||
allowedSignatureSources?: SignatureSource[];
|
||||
defaultSignatureSource?: SignatureSource;
|
||||
defaultSignatureType?: SignParameters["signatureType"];
|
||||
enableApplyAction?: boolean;
|
||||
};
|
||||
|
||||
const STAMP_TOOL_DEFAULT_SOURCES: SignatureSource[] = [
|
||||
"canvas",
|
||||
"image",
|
||||
"text",
|
||||
"saved",
|
||||
];
|
||||
|
||||
export const createStampTool = (config: StampToolConfig) => {
|
||||
const {
|
||||
toolId,
|
||||
translationScope = toolId,
|
||||
allowedSignatureSources = STAMP_TOOL_DEFAULT_SOURCES,
|
||||
defaultSignatureSource,
|
||||
defaultSignatureType,
|
||||
enableApplyAction = false,
|
||||
} = config;
|
||||
|
||||
const StampTool = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const translateTool = useCallback(
|
||||
(key: string, defaultValue: string) =>
|
||||
t(`${translationScope}.${key}`, defaultValue),
|
||||
[t, translationScope],
|
||||
);
|
||||
const {
|
||||
setWorkbench,
|
||||
setHasUnsavedChanges,
|
||||
unregisterUnsavedChangesChecker,
|
||||
} = useNavigation();
|
||||
const {
|
||||
setSignatureConfig,
|
||||
activateDrawMode,
|
||||
activateSignaturePlacementMode,
|
||||
deactivateDrawMode,
|
||||
updateDrawSettings,
|
||||
undo,
|
||||
redo,
|
||||
signatureApiRef,
|
||||
getImageData,
|
||||
setSignaturesApplied,
|
||||
} = useSignature();
|
||||
const { consumeFiles, selectors } = useFileContext();
|
||||
const {
|
||||
exportActions,
|
||||
getScrollState,
|
||||
activeFileIndex,
|
||||
setActiveFileIndex,
|
||||
} = useViewer();
|
||||
const base = useBaseTool(
|
||||
toolId,
|
||||
useSignParameters,
|
||||
useSignOperation,
|
||||
props,
|
||||
);
|
||||
|
||||
const allowedSignatureTypes = allowedSignatureSources.filter(
|
||||
(source): source is SignParameters["signatureType"] => source !== "saved",
|
||||
);
|
||||
const enforcedSignatureType =
|
||||
defaultSignatureType ??
|
||||
allowedSignatureTypes[0] ??
|
||||
DEFAULT_PARAMETERS.signatureType;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!allowedSignatureTypes.includes(base.params.parameters.signatureType)
|
||||
) {
|
||||
base.params.updateParameter("signatureType", enforcedSignatureType);
|
||||
}
|
||||
}, [
|
||||
allowedSignatureTypes,
|
||||
base.params.parameters.signatureType,
|
||||
base.params.updateParameter,
|
||||
enforcedSignatureType,
|
||||
]);
|
||||
|
||||
const hasOpenedViewer = useRef(false);
|
||||
const activeModeRef = useRef<"draw" | "placement" | null>(null);
|
||||
|
||||
const handleSignaturePlacement = useCallback(() => {
|
||||
activateSignaturePlacementMode();
|
||||
}, [activateSignaturePlacementMode]);
|
||||
|
||||
const handleActivateDrawMode = useCallback(() => {
|
||||
activeModeRef.current = "draw";
|
||||
activateDrawMode();
|
||||
}, [activateDrawMode]);
|
||||
|
||||
const handleActivateSignaturePlacement = useCallback(() => {
|
||||
activeModeRef.current = "placement";
|
||||
handleSignaturePlacement();
|
||||
}, [handleSignaturePlacement]);
|
||||
|
||||
const handleDeactivateSignature = useCallback(() => {
|
||||
activeModeRef.current = null;
|
||||
deactivateDrawMode();
|
||||
}, [deactivateDrawMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
|
||||
setWorkbench("viewer");
|
||||
hasOpenedViewer.current = true;
|
||||
}
|
||||
}, [base.selectedFiles.length, setWorkbench]);
|
||||
|
||||
useEffect(() => {
|
||||
setSignatureConfig(base.params.parameters);
|
||||
}, [base.params.parameters, setSignatureConfig]);
|
||||
|
||||
const handleSaveToSystem = useCallback(async () => {
|
||||
try {
|
||||
unregisterUnsavedChangesChecker();
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
const allFiles = selectors.getFiles();
|
||||
const fileIndex =
|
||||
activeFileIndex < allFiles.length ? activeFileIndex : 0;
|
||||
const originalFile = allFiles[fileIndex];
|
||||
|
||||
if (!originalFile) {
|
||||
console.error("No file available to replace");
|
||||
return;
|
||||
}
|
||||
|
||||
const flattenResult = await flattenSignatures({
|
||||
signatureApiRef,
|
||||
getImageData,
|
||||
exportActions,
|
||||
selectors,
|
||||
originalFile,
|
||||
getScrollState,
|
||||
activeFileIndex,
|
||||
});
|
||||
|
||||
if (flattenResult) {
|
||||
await consumeFiles(
|
||||
flattenResult.inputFileIds,
|
||||
[flattenResult.outputStirlingFile],
|
||||
[flattenResult.outputStub],
|
||||
);
|
||||
|
||||
setActiveFileIndex(0);
|
||||
setSignaturesApplied(true);
|
||||
handleDeactivateSignature();
|
||||
|
||||
const hasSignatureReady = (() => {
|
||||
const params = base.params.parameters;
|
||||
switch (params.signatureType) {
|
||||
case "canvas":
|
||||
case "image":
|
||||
return Boolean(params.signatureData);
|
||||
case "text":
|
||||
return Boolean(
|
||||
params.signerName && params.signerName.trim() !== "",
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (hasSignatureReady) {
|
||||
if (typeof window !== "undefined") {
|
||||
window.setTimeout(() => {
|
||||
handleActivateSignaturePlacement();
|
||||
}, 150);
|
||||
} else {
|
||||
handleActivateSignaturePlacement();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error("Signature flattening failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving signed document:", error);
|
||||
}
|
||||
}, [
|
||||
exportActions,
|
||||
base.selectedFiles,
|
||||
base.params.parameters,
|
||||
selectors,
|
||||
consumeFiles,
|
||||
signatureApiRef,
|
||||
getImageData,
|
||||
setWorkbench,
|
||||
activateDrawMode,
|
||||
setSignaturesApplied,
|
||||
getScrollState,
|
||||
handleDeactivateSignature,
|
||||
handleActivateSignaturePlacement,
|
||||
setHasUnsavedChanges,
|
||||
unregisterUnsavedChangesChecker,
|
||||
activeFileIndex,
|
||||
setActiveFileIndex,
|
||||
]);
|
||||
|
||||
const getSteps = () => {
|
||||
const steps = [];
|
||||
|
||||
if (base.selectedFiles.length > 0) {
|
||||
steps.push({
|
||||
title: translateTool("steps.configure", "Configure Stamp"),
|
||||
isCollapsed: false,
|
||||
onCollapsedClick: undefined,
|
||||
content: (
|
||||
<SignSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
onActivateDrawMode={handleActivateDrawMode}
|
||||
onActivateSignaturePlacement={handleActivateSignaturePlacement}
|
||||
onDeactivateSignature={handleDeactivateSignature}
|
||||
onUpdateDrawSettings={updateDrawSettings}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onSave={enableApplyAction ? handleSaveToSystem : undefined}
|
||||
translationScope={translationScope}
|
||||
allowedSignatureSources={allowedSignatureSources}
|
||||
defaultSignatureSource={defaultSignatureSource}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.operation.files.length > 0,
|
||||
},
|
||||
steps: getSteps(),
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: translateTool("results.title", "Stamp Results"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: () => {},
|
||||
},
|
||||
forceStepNumbers: true,
|
||||
});
|
||||
};
|
||||
|
||||
const StampToolComponent = StampTool as ToolComponent;
|
||||
StampToolComponent.tool = () => useSignOperation;
|
||||
StampToolComponent.getDefaultParameters = () => ({
|
||||
...DEFAULT_PARAMETERS,
|
||||
signatureType:
|
||||
config.defaultSignatureType ?? DEFAULT_PARAMETERS.signatureType,
|
||||
});
|
||||
|
||||
return StampToolComponent;
|
||||
};
|
||||
Reference in New Issue
Block a user