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:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -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;
+225
View File
@@ -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;
+758
View File
@@ -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;
+281
View File
@@ -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;
+149
View File
@@ -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;
+608
View File
@@ -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;
+189
View File
@@ -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;
+57
View File
@@ -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;
+202
View File
@@ -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;
+154
View File
@@ -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;
+243
View File
@@ -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;
+45
View File
@@ -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;
+60
View File
@@ -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;
+193
View File
@@ -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;
+12
View File
@@ -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;
+115
View File
@@ -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;
};