mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Feature/v2/sign (#4485)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: James Brunton <james@stirlingpdf.com>
This commit is contained in:
parent
abc0988fdf
commit
416d79aed3
50
frontend/package-lock.json
generated
50
frontend/package-lock.json
generated
@ -12,7 +12,9 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||||
"@embedpdf/core": "^1.3.0",
|
"@embedpdf/core": "^1.3.0",
|
||||||
"@embedpdf/engines": "^1.2.1",
|
"@embedpdf/engines": "^1.2.1",
|
||||||
|
"@embedpdf/plugin-annotation": "^1.3.0",
|
||||||
"@embedpdf/plugin-export": "^1.3.0",
|
"@embedpdf/plugin-export": "^1.3.0",
|
||||||
|
"@embedpdf/plugin-history": "^1.3.0",
|
||||||
"@embedpdf/plugin-interaction-manager": "^1.3.0",
|
"@embedpdf/plugin-interaction-manager": "^1.3.0",
|
||||||
"@embedpdf/plugin-loader": "^1.3.0",
|
"@embedpdf/plugin-loader": "^1.3.0",
|
||||||
"@embedpdf/plugin-pan": "^1.3.0",
|
"@embedpdf/plugin-pan": "^1.3.0",
|
||||||
@ -532,6 +534,26 @@
|
|||||||
"integrity": "sha512-rSBFYjxwQ58L/HcqR0l5Vv4G5t+CCOKlFYrDReTZYNN7fhzKPUWbXUn4ARahZWCNmF8svHumV2P4ArakJJviuw==",
|
"integrity": "sha512-rSBFYjxwQ58L/HcqR0l5Vv4G5t+CCOKlFYrDReTZYNN7fhzKPUWbXUn4ARahZWCNmF8svHumV2P4ArakJJviuw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@embedpdf/plugin-annotation": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-W9N8kQebnOT5ci7pp4RRPXK2ZAMvQbdd4Qkt4vXAsL9QIKqprAMrvo0GKzUAqMaYUk9WhVHgc5zwpeSP3PVUHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@embedpdf/models": "1.3.0",
|
||||||
|
"@embedpdf/utils": "1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@embedpdf/core": "1.3.0",
|
||||||
|
"@embedpdf/plugin-history": "1.3.0",
|
||||||
|
"@embedpdf/plugin-interaction-manager": "1.3.0",
|
||||||
|
"@embedpdf/plugin-selection": "1.3.0",
|
||||||
|
"preact": "^10.26.4",
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0",
|
||||||
|
"vue": ">=3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@embedpdf/plugin-export": {
|
"node_modules/@embedpdf/plugin-export": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.0.tgz",
|
||||||
@ -548,6 +570,22 @@
|
|||||||
"vue": ">=3.2.0"
|
"vue": ">=3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@embedpdf/plugin-history": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-HiNig94e6jE4h3BTL8Yi1fLLtYPY50N7vrkHSImqDmUTIcNHbQVbBYVCPpFJxN5NtuuaQqN9p1Mr7DwOKX8lkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@embedpdf/models": "1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@embedpdf/core": "1.3.0",
|
||||||
|
"preact": "^10.26.4",
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0",
|
||||||
|
"vue": ">=3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.0.tgz",
|
||||||
@ -770,6 +808,18 @@
|
|||||||
"vue": ">=3.2.0"
|
"vue": ">=3.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@embedpdf/utils": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-KEgdR85vd2CNKoSBoE5h4+e1n7MqEuIq3jZwD9MXAVKpHMaAIuD+S1khD8m4XLnbQXn32A9cO6Z6fmH0ndZ7+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": "^10.26.4",
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0",
|
||||||
|
"vue": ">=3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emotion/babel-plugin": {
|
"node_modules/@emotion/babel-plugin": {
|
||||||
"version": "11.13.5",
|
"version": "11.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||||
"@embedpdf/core": "^1.3.0",
|
"@embedpdf/core": "^1.3.0",
|
||||||
"@embedpdf/engines": "^1.2.1",
|
"@embedpdf/engines": "^1.2.1",
|
||||||
|
"@embedpdf/plugin-annotation": "^1.3.0",
|
||||||
"@embedpdf/plugin-export": "^1.3.0",
|
"@embedpdf/plugin-export": "^1.3.0",
|
||||||
|
"@embedpdf/plugin-history": "^1.3.0",
|
||||||
"@embedpdf/plugin-interaction-manager": "^1.3.0",
|
"@embedpdf/plugin-interaction-manager": "^1.3.0",
|
||||||
"@embedpdf/plugin-loader": "^1.3.0",
|
"@embedpdf/plugin-loader": "^1.3.0",
|
||||||
"@embedpdf/plugin-pan": "^1.3.0",
|
"@embedpdf/plugin-pan": "^1.3.0",
|
||||||
|
|||||||
@ -1732,6 +1732,7 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"saved": "Saved Signatures",
|
"saved": "Saved Signatures",
|
||||||
"save": "Save Signature",
|
"save": "Save Signature",
|
||||||
|
"applySignatures": "Apply Signatures",
|
||||||
"personalSigs": "Personal Signatures",
|
"personalSigs": "Personal Signatures",
|
||||||
"sharedSigs": "Shared Signatures",
|
"sharedSigs": "Shared Signatures",
|
||||||
"noSavedSigs": "No saved signatures found",
|
"noSavedSigs": "No saved signatures found",
|
||||||
@ -1743,7 +1744,42 @@
|
|||||||
"previous": "Previous page",
|
"previous": "Previous page",
|
||||||
"maintainRatio": "Toggle maintain aspect ratio",
|
"maintainRatio": "Toggle maintain aspect ratio",
|
||||||
"undo": "Undo",
|
"undo": "Undo",
|
||||||
"redo": "Redo"
|
"redo": "Redo",
|
||||||
|
"submit": "Sign Document",
|
||||||
|
"steps": {
|
||||||
|
"configure": "Configure Signature"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"title": "Signature Type",
|
||||||
|
"draw": "Draw",
|
||||||
|
"canvas": "Canvas",
|
||||||
|
"image": "Image",
|
||||||
|
"text": "Text"
|
||||||
|
},
|
||||||
|
"draw": {
|
||||||
|
"title": "Draw your signature",
|
||||||
|
"clear": "Clear"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"label": "Upload signature image",
|
||||||
|
"placeholder": "Select image file",
|
||||||
|
"hint": "Upload a PNG or JPG image of your signature"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"name": "Signer Name",
|
||||||
|
"placeholder": "Enter your full name"
|
||||||
|
},
|
||||||
|
"instructions": {
|
||||||
|
"title": "How to add signature"
|
||||||
|
},
|
||||||
|
"activate": "Activate Signature Placement",
|
||||||
|
"deactivate": "Stop Placing Signatures",
|
||||||
|
"results": {
|
||||||
|
"title": "Signature Results"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"failed": "An error occurred while signing the PDF."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"flatten": {
|
"flatten": {
|
||||||
"title": "Flatten",
|
"title": "Flatten",
|
||||||
@ -3385,6 +3421,16 @@
|
|||||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"viewer": {
|
||||||
|
"firstPage": "First Page",
|
||||||
|
"lastPage": "Last Page",
|
||||||
|
"previousPage": "Previous Page",
|
||||||
|
"nextPage": "Next Page",
|
||||||
|
"zoomIn": "Zoom In",
|
||||||
|
"zoomOut": "Zoom Out",
|
||||||
|
"singlePageView": "Single Page View",
|
||||||
|
"dualPageView": "Dual Page View"
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
const { execSync } = require('node:child_process');
|
||||||
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
|
||||||
import * as path from 'node:path';
|
const path = require('node:path');
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
import { argv } from 'node:process';
|
const { argv } = require('node:process');
|
||||||
const inputIdx = argv.indexOf('--input');
|
const inputIdx = argv.indexOf('--input');
|
||||||
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
|
||||||
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
// __dirname is available in CommonJS by default
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate 3rd party licenses for frontend dependencies
|
* Generate 3rd party licenses for frontend dependencies
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import "./styles/cookieconsent.css";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||||
|
import { SignatureProvider } from "./contexts/SignatureContext";
|
||||||
|
|
||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "./utils/fileIdSafety";
|
import "./utils/fileIdSafety";
|
||||||
@ -45,9 +46,11 @@ export default function App() {
|
|||||||
<ToolWorkflowProvider>
|
<ToolWorkflowProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<ViewerProvider>
|
<ViewerProvider>
|
||||||
|
<SignatureProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
|
</SignatureProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</ToolWorkflowProvider>
|
</ToolWorkflowProvider>
|
||||||
|
|||||||
@ -7,6 +7,132 @@
|
|||||||
"moduleLicense": "Apache-2.0",
|
"moduleLicense": "Apache-2.0",
|
||||||
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/core",
|
||||||
|
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/engines",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-annotation",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-export",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-history",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-interaction-manager",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-loader",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-pan",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-render",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-rotate",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-scroll",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-search",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-selection",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-spread",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-thumbnail",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-tiling",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-viewport",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@embedpdf/plugin-zoom",
|
||||||
|
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
|
||||||
|
"moduleVersion": "1.3.0",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@emotion/react",
|
"moduleName": "@emotion/react",
|
||||||
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
|
||||||
@ -35,6 +161,13 @@
|
|||||||
"moduleLicense": "MIT",
|
"moduleLicense": "MIT",
|
||||||
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"moduleName": "@mantine/dates",
|
||||||
|
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||||
|
"moduleVersion": "8.3.1",
|
||||||
|
"moduleLicense": "MIT",
|
||||||
|
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"moduleName": "@mantine/dropzone",
|
"moduleName": "@mantine/dropzone",
|
||||||
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
|
||||||
@ -143,7 +276,7 @@
|
|||||||
{
|
{
|
||||||
"moduleName": "posthog-js",
|
"moduleName": "posthog-js",
|
||||||
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
|
||||||
"moduleVersion": "1.266.0",
|
"moduleVersion": "1.268.0",
|
||||||
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
|
||||||
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface PDFAnnotationContextValue {
|
||||||
|
// Drawing mode management
|
||||||
|
activateDrawMode: () => void;
|
||||||
|
deactivateDrawMode: () => void;
|
||||||
|
activateSignaturePlacementMode: () => void;
|
||||||
|
activateDeleteMode: () => void;
|
||||||
|
|
||||||
|
// Drawing settings
|
||||||
|
updateDrawSettings: (color: string, size: number) => void;
|
||||||
|
|
||||||
|
// History operations
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
|
||||||
|
// Image data management
|
||||||
|
storeImageData: (id: string, data: string) => void;
|
||||||
|
getImageData: (id: string) => string | undefined;
|
||||||
|
|
||||||
|
// Placement state
|
||||||
|
isPlacementMode: boolean;
|
||||||
|
|
||||||
|
// Signature configuration
|
||||||
|
signatureConfig: any | null;
|
||||||
|
setSignatureConfig: (config: any | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
interface PDFAnnotationProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
// These would come from the signature context
|
||||||
|
activateDrawMode: () => void;
|
||||||
|
deactivateDrawMode: () => void;
|
||||||
|
activateSignaturePlacementMode: () => void;
|
||||||
|
activateDeleteMode: () => void;
|
||||||
|
updateDrawSettings: (color: string, size: number) => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
storeImageData: (id: string, data: string) => void;
|
||||||
|
getImageData: (id: string) => string | undefined;
|
||||||
|
isPlacementMode: boolean;
|
||||||
|
signatureConfig: any | null;
|
||||||
|
setSignatureConfig: (config: any | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||||
|
children,
|
||||||
|
activateDrawMode,
|
||||||
|
deactivateDrawMode,
|
||||||
|
activateSignaturePlacementMode,
|
||||||
|
activateDeleteMode,
|
||||||
|
updateDrawSettings,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
storeImageData,
|
||||||
|
getImageData,
|
||||||
|
isPlacementMode,
|
||||||
|
signatureConfig,
|
||||||
|
setSignatureConfig
|
||||||
|
}) => {
|
||||||
|
const contextValue: PDFAnnotationContextValue = {
|
||||||
|
activateDrawMode,
|
||||||
|
deactivateDrawMode,
|
||||||
|
activateSignaturePlacementMode,
|
||||||
|
activateDeleteMode,
|
||||||
|
updateDrawSettings,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
storeImageData,
|
||||||
|
getImageData,
|
||||||
|
isPlacementMode,
|
||||||
|
signatureConfig,
|
||||||
|
setSignatureConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PDFAnnotationContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</PDFAnnotationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||||
|
const context = useContext(PDFAnnotationContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Stack, Alert, Text } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { DrawingControls } from './DrawingControls';
|
||||||
|
import { ColorPicker } from './ColorPicker';
|
||||||
|
import { usePDFAnnotation } from '../providers/PDFAnnotationProvider';
|
||||||
|
|
||||||
|
export interface AnnotationToolConfig {
|
||||||
|
enableDrawing?: boolean;
|
||||||
|
enableImageUpload?: boolean;
|
||||||
|
enableTextInput?: boolean;
|
||||||
|
showPlaceButton?: boolean;
|
||||||
|
placeButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseAnnotationToolProps {
|
||||||
|
config: AnnotationToolConfig;
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSignatureDataChange?: (data: string | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||||
|
config,
|
||||||
|
children,
|
||||||
|
onSignatureDataChange,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
activateSignaturePlacementMode,
|
||||||
|
undo,
|
||||||
|
redo
|
||||||
|
} = usePDFAnnotation();
|
||||||
|
|
||||||
|
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||||
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSignatureDataChange = (data: string | null) => {
|
||||||
|
setSignatureData(data);
|
||||||
|
onSignatureDataChange?.(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaceSignature = () => {
|
||||||
|
if (activateSignaturePlacementMode) {
|
||||||
|
activateSignaturePlacementMode();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Drawing Controls (Undo/Redo/Place) */}
|
||||||
|
<DrawingControls
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||||
|
hasSignatureData={!!signatureData}
|
||||||
|
disabled={disabled}
|
||||||
|
showPlaceButton={config.showPlaceButton}
|
||||||
|
placeButtonText={config.placeButtonText}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tool Content */}
|
||||||
|
{React.cloneElement(children as React.ReactElement<any>, {
|
||||||
|
selectedColor,
|
||||||
|
signatureData,
|
||||||
|
onSignatureDataChange: handleSignatureDataChange,
|
||||||
|
onColorSwatchClick: () => setIsColorPickerOpen(true),
|
||||||
|
disabled
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Instructions for placing signature */}
|
||||||
|
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||||
|
<Text size="sm">
|
||||||
|
Click anywhere on the PDF to place your annotation.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Color Picker Modal */}
|
||||||
|
<ColorPicker
|
||||||
|
isOpen={isColorPickerOpen}
|
||||||
|
onClose={() => setIsColorPickerOpen(false)}
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
onColorChange={setSelectedColor}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
frontend/src/components/annotation/shared/ColorPicker.tsx
Normal file
67
frontend/src/components/annotation/shared/ColorPicker.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
|
||||||
|
|
||||||
|
interface ColorPickerProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
selectedColor: string;
|
||||||
|
onColorChange: (color: string) => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
selectedColor,
|
||||||
|
onColorChange,
|
||||||
|
title = "Choose Color"
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
size="sm"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<MantineColorPicker
|
||||||
|
format="hex"
|
||||||
|
value={selectedColor}
|
||||||
|
onChange={onColorChange}
|
||||||
|
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
|
||||||
|
swatchesPerRow={6}
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ColorSwatchButtonProps {
|
||||||
|
color: string;
|
||||||
|
onClick: () => void;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||||
|
color,
|
||||||
|
onClick,
|
||||||
|
size = 24
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ColorSwatch
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
radius={0}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
437
frontend/src/components/annotation/shared/DrawingCanvas.tsx
Normal file
437
frontend/src/components/annotation/shared/DrawingCanvas.tsx
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
|
||||||
|
import { ColorSwatchButton } from './ColorPicker';
|
||||||
|
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
|
||||||
|
|
||||||
|
interface DrawingCanvasProps {
|
||||||
|
selectedColor: string;
|
||||||
|
penSize: number;
|
||||||
|
penSizeInput: string;
|
||||||
|
onColorSwatchClick: () => void;
|
||||||
|
onPenSizeChange: (size: number) => void;
|
||||||
|
onPenSizeInputChange: (input: string) => void;
|
||||||
|
onSignatureDataChange: (data: string | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
modalWidth?: number;
|
||||||
|
modalHeight?: number;
|
||||||
|
additionalButtons?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||||
|
selectedColor,
|
||||||
|
penSize,
|
||||||
|
penSizeInput,
|
||||||
|
onColorSwatchClick,
|
||||||
|
onPenSizeChange,
|
||||||
|
onPenSizeInputChange,
|
||||||
|
onSignatureDataChange,
|
||||||
|
disabled = false,
|
||||||
|
width = 400,
|
||||||
|
height = 150,
|
||||||
|
modalWidth = 800,
|
||||||
|
modalHeight = 400,
|
||||||
|
additionalButtons
|
||||||
|
}) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const visibleModalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
|
const [isModalDrawing, setIsModalDrawing] = useState(false);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Drawing functions for main canvas
|
||||||
|
const startDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!canvasRef.current || disabled) return;
|
||||||
|
|
||||||
|
setIsDrawing(true);
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const scaleX = canvasRef.current.width / rect.width;
|
||||||
|
const scaleY = canvasRef.current.height / rect.height;
|
||||||
|
const x = (e.clientX - rect.left) * scaleX;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
const ctx = canvasRef.current.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.strokeStyle = selectedColor;
|
||||||
|
ctx.lineWidth = penSize;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
}
|
||||||
|
}, [disabled, selectedColor, penSize]);
|
||||||
|
|
||||||
|
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isDrawing || !canvasRef.current || disabled) return;
|
||||||
|
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
|
const scaleX = canvasRef.current.width / rect.width;
|
||||||
|
const scaleY = canvasRef.current.height / rect.height;
|
||||||
|
const x = (e.clientX - rect.left) * scaleX;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
const ctx = canvasRef.current.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
}, [isDrawing, disabled]);
|
||||||
|
|
||||||
|
const stopDrawing = useCallback(() => {
|
||||||
|
if (!isDrawing || disabled) return;
|
||||||
|
|
||||||
|
setIsDrawing(false);
|
||||||
|
|
||||||
|
// Save canvas as signature data
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const dataURL = canvasRef.current.toDataURL('image/png');
|
||||||
|
onSignatureDataChange(dataURL);
|
||||||
|
}
|
||||||
|
}, [isDrawing, disabled, onSignatureDataChange]);
|
||||||
|
|
||||||
|
// Modal canvas drawing functions
|
||||||
|
const startModalDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!visibleModalCanvasRef.current || !modalCanvasRef.current) return;
|
||||||
|
|
||||||
|
setIsModalDrawing(true);
|
||||||
|
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
|
||||||
|
const scaleX = visibleModalCanvasRef.current.width / rect.width;
|
||||||
|
const scaleY = visibleModalCanvasRef.current.height / rect.height;
|
||||||
|
const x = (e.clientX - rect.left) * scaleX;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
// Draw on both the visible modal canvas and hidden canvas
|
||||||
|
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||||
|
const hiddenCtx = modalCanvasRef.current.getContext('2d');
|
||||||
|
|
||||||
|
[visibleCtx, hiddenCtx].forEach(ctx => {
|
||||||
|
if (ctx) {
|
||||||
|
ctx.strokeStyle = selectedColor;
|
||||||
|
ctx.lineWidth = penSize;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [selectedColor, penSize]);
|
||||||
|
|
||||||
|
const drawModal = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!isModalDrawing || !visibleModalCanvasRef.current || !modalCanvasRef.current) return;
|
||||||
|
|
||||||
|
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
|
||||||
|
const scaleX = visibleModalCanvasRef.current.width / rect.width;
|
||||||
|
const scaleY = visibleModalCanvasRef.current.height / rect.height;
|
||||||
|
const x = (e.clientX - rect.left) * scaleX;
|
||||||
|
const y = (e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
// Draw on both canvases
|
||||||
|
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||||
|
const hiddenCtx = modalCanvasRef.current.getContext('2d');
|
||||||
|
|
||||||
|
[visibleCtx, hiddenCtx].forEach(ctx => {
|
||||||
|
if (ctx) {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isModalDrawing]);
|
||||||
|
|
||||||
|
const stopModalDrawing = useCallback(() => {
|
||||||
|
if (!isModalDrawing) return;
|
||||||
|
setIsModalDrawing(false);
|
||||||
|
|
||||||
|
// Sync the canvases and update signature data (only when drawing stops)
|
||||||
|
if (modalCanvasRef.current) {
|
||||||
|
const dataURL = modalCanvasRef.current.toDataURL('image/png');
|
||||||
|
onSignatureDataChange(dataURL);
|
||||||
|
|
||||||
|
// Also update the small canvas display
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const smallCtx = canvasRef.current.getContext('2d');
|
||||||
|
if (smallCtx) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
smallCtx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||||
|
smallCtx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||||
|
};
|
||||||
|
img.src = dataURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isModalDrawing]);
|
||||||
|
|
||||||
|
// Clear canvas functions
|
||||||
|
const clearCanvas = useCallback(() => {
|
||||||
|
if (!canvasRef.current || disabled) return;
|
||||||
|
|
||||||
|
const ctx = canvasRef.current.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||||
|
|
||||||
|
// Also clear the modal canvas if it exists
|
||||||
|
if (modalCanvasRef.current) {
|
||||||
|
const modalCtx = modalCanvasRef.current.getContext('2d');
|
||||||
|
if (modalCtx) {
|
||||||
|
modalCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSignatureDataChange(null);
|
||||||
|
}
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const clearModalCanvas = useCallback(() => {
|
||||||
|
// Clear both modal canvases (visible and hidden)
|
||||||
|
if (modalCanvasRef.current) {
|
||||||
|
const hiddenCtx = modalCanvasRef.current.getContext('2d');
|
||||||
|
if (hiddenCtx) {
|
||||||
|
hiddenCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleModalCanvasRef.current) {
|
||||||
|
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||||
|
if (visibleCtx) {
|
||||||
|
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear the main canvas and signature data
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const mainCtx = canvasRef.current.getContext('2d');
|
||||||
|
if (mainCtx) {
|
||||||
|
mainCtx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSignatureDataChange(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveModalSignature = useCallback(() => {
|
||||||
|
if (!modalCanvasRef.current) return;
|
||||||
|
|
||||||
|
const dataURL = modalCanvasRef.current.toDataURL('image/png');
|
||||||
|
onSignatureDataChange(dataURL);
|
||||||
|
|
||||||
|
// Copy to small canvas for display
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const ctx = canvasRef.current.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||||
|
ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
|
||||||
|
};
|
||||||
|
img.src = dataURL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openModal = useCallback(() => {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
// Copy content to modal canvas after a brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (visibleModalCanvasRef.current && modalCanvasRef.current) {
|
||||||
|
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
|
||||||
|
if (visibleCtx) {
|
||||||
|
visibleCtx.strokeStyle = selectedColor;
|
||||||
|
visibleCtx.lineWidth = penSize;
|
||||||
|
visibleCtx.lineCap = 'round';
|
||||||
|
visibleCtx.lineJoin = 'round';
|
||||||
|
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
|
||||||
|
visibleCtx.drawImage(modalCanvasRef.current, 0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, [selectedColor, penSize]);
|
||||||
|
|
||||||
|
// Initialize canvas settings whenever color or pen size changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateCanvas = (canvas: HTMLCanvasElement | null) => {
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.strokeStyle = selectedColor;
|
||||||
|
ctx.lineWidth = penSize;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCanvas(canvasRef.current);
|
||||||
|
updateCanvas(modalCanvasRef.current);
|
||||||
|
updateCanvas(visibleModalCanvasRef.current);
|
||||||
|
}, [selectedColor, penSize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={500}>Draw your signature</Text>
|
||||||
|
<Group gap="lg">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="xs" ta="center">Color</Text>
|
||||||
|
<Group justify="center">
|
||||||
|
<ColorSwatchButton
|
||||||
|
color={selectedColor}
|
||||||
|
onClick={onColorSwatchClick}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||||
|
<PenSizeSelector
|
||||||
|
value={penSize}
|
||||||
|
inputValue={penSizeInput}
|
||||||
|
onValueChange={onPenSizeChange}
|
||||||
|
onInputChange={onPenSizeInputChange}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder="Size"
|
||||||
|
size="compact-sm"
|
||||||
|
style={{ width: '60px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ paddingTop: '24px' }}>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={openModal}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Expand
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: disabled ? 'default' : 'crosshair',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
onMouseDown={startDrawing}
|
||||||
|
onMouseMove={draw}
|
||||||
|
onMouseUp={stopDrawing}
|
||||||
|
onMouseLeave={stopDrawing}
|
||||||
|
/>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
{additionalButtons}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
size="compact-sm"
|
||||||
|
onClick={clearCanvas}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Hidden canvas for modal synchronization */}
|
||||||
|
<canvas
|
||||||
|
ref={modalCanvasRef}
|
||||||
|
width={modalWidth}
|
||||||
|
height={modalHeight}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal for larger signature canvas */}
|
||||||
|
<Modal
|
||||||
|
opened={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
title="Draw Your Signature"
|
||||||
|
size="xl"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Color and Pen Size picker */}
|
||||||
|
<Paper withBorder p="sm">
|
||||||
|
<Group gap="lg" align="flex-end">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="xs">Color</Text>
|
||||||
|
<ColorSwatchButton
|
||||||
|
color={selectedColor}
|
||||||
|
onClick={onColorSwatchClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
|
||||||
|
<PenSizeSelector
|
||||||
|
value={penSize}
|
||||||
|
inputValue={penSizeInput}
|
||||||
|
onValueChange={onPenSizeChange}
|
||||||
|
onInputChange={onPenSizeInputChange}
|
||||||
|
placeholder="Size"
|
||||||
|
size="compact-sm"
|
||||||
|
style={{ width: '60px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md">
|
||||||
|
<canvas
|
||||||
|
ref={visibleModalCanvasRef}
|
||||||
|
width={modalWidth}
|
||||||
|
height={modalHeight}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'crosshair',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: `${modalWidth}px`,
|
||||||
|
height: 'auto',
|
||||||
|
}}
|
||||||
|
onMouseDown={startModalDrawing}
|
||||||
|
onMouseMove={drawModal}
|
||||||
|
onMouseUp={stopModalDrawing}
|
||||||
|
onMouseLeave={stopModalDrawing}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
onClick={clearModalCanvas}
|
||||||
|
>
|
||||||
|
Clear Canvas
|
||||||
|
</Button>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={saveModalSignature}
|
||||||
|
>
|
||||||
|
Save Signature
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DrawingCanvas;
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Group, Button } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface DrawingControlsProps {
|
||||||
|
onUndo?: () => void;
|
||||||
|
onRedo?: () => void;
|
||||||
|
onPlaceSignature?: () => void;
|
||||||
|
hasSignatureData?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
showPlaceButton?: boolean;
|
||||||
|
placeButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onPlaceSignature,
|
||||||
|
hasSignatureData = false,
|
||||||
|
disabled = false,
|
||||||
|
showPlaceButton = true,
|
||||||
|
placeButtonText = "Update and Place"
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap="sm">
|
||||||
|
{/* Undo/Redo Controls */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={disabled}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{t('sign.undo', 'Undo')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={disabled}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{t('sign.redo', 'Redo')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Place Signature Button */}
|
||||||
|
{showPlaceButton && onPlaceSignature && (
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
onClick={onPlaceSignature}
|
||||||
|
disabled={disabled || !hasSignatureData}
|
||||||
|
flex={1}
|
||||||
|
>
|
||||||
|
{placeButtonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
frontend/src/components/annotation/shared/ImageUploader.tsx
Normal file
55
frontend/src/components/annotation/shared/ImageUploader.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FileInput, Text, Stack } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface ImageUploaderProps {
|
||||||
|
onImageChange: (file: File | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||||
|
onImageChange,
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
hint
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handleImageChange = async (file: File | null) => {
|
||||||
|
if (file && !disabled) {
|
||||||
|
try {
|
||||||
|
// Validate that it's actually an image file
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
console.error('Selected file is not an image');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageChange(file);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing image file:', error);
|
||||||
|
}
|
||||||
|
} else if (!file) {
|
||||||
|
// Clear image data when no file is selected
|
||||||
|
onImageChange(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<FileInput
|
||||||
|
label={label || t('sign.image.label', 'Upload signature image')}
|
||||||
|
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
frontend/src/components/annotation/shared/TextInputWithFont.tsx
Normal file
126
frontend/src/components/annotation/shared/TextInputWithFont.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface TextInputWithFontProps {
|
||||||
|
text: string;
|
||||||
|
onTextChange: (text: string) => void;
|
||||||
|
fontSize: number;
|
||||||
|
onFontSizeChange: (size: number) => void;
|
||||||
|
fontFamily: string;
|
||||||
|
onFontFamilyChange: (family: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
|
||||||
|
text,
|
||||||
|
onTextChange,
|
||||||
|
fontSize,
|
||||||
|
onFontSizeChange,
|
||||||
|
fontFamily,
|
||||||
|
onFontFamilyChange,
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
placeholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
|
||||||
|
const fontSizeCombobox = useCombobox();
|
||||||
|
|
||||||
|
// Sync font size input with prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFontSizeInput(fontSize.toString());
|
||||||
|
}, [fontSize]);
|
||||||
|
|
||||||
|
const fontOptions = [
|
||||||
|
{ value: 'Helvetica', label: 'Helvetica' },
|
||||||
|
{ value: 'Times-Roman', label: 'Times' },
|
||||||
|
{ value: 'Courier', label: 'Courier' },
|
||||||
|
{ value: 'Arial', label: 'Arial' },
|
||||||
|
{ value: 'Georgia', label: 'Georgia' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
label={label || t('sign.text.name', 'Signer Name')}
|
||||||
|
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => onTextChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Font Selection */}
|
||||||
|
<Select
|
||||||
|
label="Font"
|
||||||
|
value={fontFamily}
|
||||||
|
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
|
||||||
|
data={fontOptions}
|
||||||
|
disabled={disabled}
|
||||||
|
searchable
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Font Size */}
|
||||||
|
<Combobox
|
||||||
|
onOptionSubmit={(optionValue) => {
|
||||||
|
setFontSizeInput(optionValue);
|
||||||
|
const size = parseInt(optionValue);
|
||||||
|
if (!isNaN(size)) {
|
||||||
|
onFontSizeChange(size);
|
||||||
|
}
|
||||||
|
fontSizeCombobox.closeDropdown();
|
||||||
|
}}
|
||||||
|
store={fontSizeCombobox}
|
||||||
|
withinPortal={false}
|
||||||
|
>
|
||||||
|
<Combobox.Target>
|
||||||
|
<TextInput
|
||||||
|
label="Font Size"
|
||||||
|
placeholder="Type or select font size (8-72)"
|
||||||
|
value={fontSizeInput}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.currentTarget.value;
|
||||||
|
setFontSizeInput(value);
|
||||||
|
|
||||||
|
// Parse and validate the typed value in real-time
|
||||||
|
const size = parseInt(value);
|
||||||
|
if (!isNaN(size) && size >= 8 && size <= 72) {
|
||||||
|
onFontSizeChange(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
fontSizeCombobox.openDropdown();
|
||||||
|
fontSizeCombobox.updateSelectedOptionIndex();
|
||||||
|
}}
|
||||||
|
onClick={() => fontSizeCombobox.openDropdown()}
|
||||||
|
onFocus={() => fontSizeCombobox.openDropdown()}
|
||||||
|
onBlur={() => {
|
||||||
|
fontSizeCombobox.closeDropdown();
|
||||||
|
// Clean up invalid values on blur
|
||||||
|
const size = parseInt(fontSizeInput);
|
||||||
|
if (isNaN(size) || size < 8 || size > 72) {
|
||||||
|
setFontSizeInput(fontSize.toString());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Options>
|
||||||
|
{fontSizeOptions.map((size) => (
|
||||||
|
<Combobox.Option value={size} key={size}>
|
||||||
|
{size}px
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
45
frontend/src/components/annotation/tools/DrawingTool.tsx
Normal file
45
frontend/src/components/annotation/tools/DrawingTool.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||||
|
import { DrawingCanvas } from '../shared/DrawingCanvas';
|
||||||
|
|
||||||
|
interface DrawingToolProps {
|
||||||
|
onDrawingChange?: (data: string | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||||
|
onDrawingChange,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const [selectedColor] = useState('#000000');
|
||||||
|
const [penSize, setPenSize] = useState(2);
|
||||||
|
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||||
|
|
||||||
|
const toolConfig = {
|
||||||
|
enableDrawing: true,
|
||||||
|
showPlaceButton: true,
|
||||||
|
placeButtonText: "Place Drawing"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseAnnotationTool
|
||||||
|
config={toolConfig}
|
||||||
|
onSignatureDataChange={onDrawingChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<DrawingCanvas
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
penSize={penSize}
|
||||||
|
penSizeInput={penSizeInput}
|
||||||
|
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
|
||||||
|
onPenSizeChange={setPenSize}
|
||||||
|
onPenSizeInputChange={setPenSizeInput}
|
||||||
|
onSignatureDataChange={onDrawingChange || (() => {})}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</BaseAnnotationTool>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
frontend/src/components/annotation/tools/ImageTool.tsx
Normal file
67
frontend/src/components/annotation/tools/ImageTool.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||||
|
import { ImageUploader } from '../shared/ImageUploader';
|
||||||
|
|
||||||
|
interface ImageToolProps {
|
||||||
|
onImageChange?: (data: string | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||||
|
onImageChange,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const [, setImageData] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleImageUpload = async (file: File | null) => {
|
||||||
|
if (file && !disabled) {
|
||||||
|
try {
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
if (e.target?.result) {
|
||||||
|
resolve(e.target.result as string);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
setImageData(result);
|
||||||
|
onImageChange?.(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading file:', error);
|
||||||
|
}
|
||||||
|
} else if (!file) {
|
||||||
|
setImageData(null);
|
||||||
|
onImageChange?.(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolConfig = {
|
||||||
|
enableImageUpload: true,
|
||||||
|
showPlaceButton: true,
|
||||||
|
placeButtonText: "Place Image"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseAnnotationTool
|
||||||
|
config={toolConfig}
|
||||||
|
onSignatureDataChange={onImageChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<ImageUploader
|
||||||
|
onImageChange={handleImageUpload}
|
||||||
|
disabled={disabled}
|
||||||
|
label="Upload Image"
|
||||||
|
placeholder="Select image file"
|
||||||
|
hint="Upload a PNG, JPG, or other image file to place on the PDF"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</BaseAnnotationTool>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
frontend/src/components/annotation/tools/TextTool.tsx
Normal file
57
frontend/src/components/annotation/tools/TextTool.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
|
||||||
|
import { TextInputWithFont } from '../shared/TextInputWithFont';
|
||||||
|
|
||||||
|
interface TextToolProps {
|
||||||
|
onTextChange?: (text: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextTool: React.FC<TextToolProps> = ({
|
||||||
|
onTextChange,
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [fontSize, setFontSize] = useState(16);
|
||||||
|
const [fontFamily, setFontFamily] = useState('Helvetica');
|
||||||
|
|
||||||
|
const handleTextChange = (newText: string) => {
|
||||||
|
setText(newText);
|
||||||
|
onTextChange?.(newText);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignatureDataChange = (data: string | null) => {
|
||||||
|
if (data) {
|
||||||
|
onTextChange?.(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolConfig = {
|
||||||
|
enableTextInput: true,
|
||||||
|
showPlaceButton: true,
|
||||||
|
placeButtonText: "Place Text"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseAnnotationTool
|
||||||
|
config={toolConfig}
|
||||||
|
onSignatureDataChange={handleSignatureDataChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInputWithFont
|
||||||
|
text={text}
|
||||||
|
onTextChange={handleTextChange}
|
||||||
|
fontSize={fontSize}
|
||||||
|
onFontSizeChange={setFontSize}
|
||||||
|
fontFamily={fontFamily}
|
||||||
|
onFontFamilyChange={setFontFamily}
|
||||||
|
disabled={disabled}
|
||||||
|
label="Text Content"
|
||||||
|
placeholder="Enter text to place on the PDF"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</BaseAnnotationTool>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,6 +14,7 @@ import { Tooltip } from '../shared/Tooltip';
|
|||||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||||
import { SearchInterface } from '../viewer/SearchInterface';
|
import { SearchInterface } from '../viewer/SearchInterface';
|
||||||
import { ViewerContext } from '../../contexts/ViewerContext';
|
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||||
|
import { useSignature } from '../../contexts/SignatureContext';
|
||||||
|
|
||||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||||
|
|
||||||
@ -43,6 +44,9 @@ export default function RightRail() {
|
|||||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||||
const { removeFiles } = useFileManagement();
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
|
// Signature context for checking if signatures have been applied
|
||||||
|
const { signaturesApplied } = useSignature();
|
||||||
|
|
||||||
const activeFiles = selectors.getFiles();
|
const activeFiles = selectors.getFiles();
|
||||||
const filesSignature = selectors.getFilesSignature();
|
const filesSignature = selectors.getFilesSignature();
|
||||||
|
|
||||||
@ -98,8 +102,14 @@ export default function RightRail() {
|
|||||||
}
|
}
|
||||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||||
|
|
||||||
const handleExportAll = useCallback(() => {
|
const handleExportAll = useCallback(async () => {
|
||||||
if (currentView === 'viewer') {
|
if (currentView === 'viewer') {
|
||||||
|
// Check if signatures have been applied
|
||||||
|
if (!signaturesApplied) {
|
||||||
|
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use EmbedPDF export functionality for viewer mode
|
// Use EmbedPDF export functionality for viewer mode
|
||||||
viewerContext?.exportActions?.download();
|
viewerContext?.exportActions?.download();
|
||||||
} else if (currentView === 'fileEditor') {
|
} else if (currentView === 'fileEditor') {
|
||||||
@ -119,7 +129,7 @@ export default function RightRail() {
|
|||||||
// Export all pages (not just selected)
|
// Export all pages (not just selected)
|
||||||
pageEditorFunctions?.onExportAll?.();
|
pageEditorFunctions?.onExportAll?.();
|
||||||
}
|
}
|
||||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext]);
|
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
|
||||||
|
|
||||||
const handleCloseSelected = useCallback(() => {
|
const handleCloseSelected = useCallback(() => {
|
||||||
if (currentView !== 'fileEditor') return;
|
if (currentView !== 'fileEditor') return;
|
||||||
|
|||||||
86
frontend/src/components/tools/sign/PenSizeSelector.tsx
Normal file
86
frontend/src/components/tools/sign/PenSizeSelector.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TextInput, Combobox, useCombobox } from '@mantine/core';
|
||||||
|
|
||||||
|
interface PenSizeSelectorProps {
|
||||||
|
value: number;
|
||||||
|
inputValue: string;
|
||||||
|
onValueChange: (size: number) => void;
|
||||||
|
onInputChange: (input: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PenSizeSelector = ({
|
||||||
|
value,
|
||||||
|
inputValue,
|
||||||
|
onValueChange,
|
||||||
|
onInputChange,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = "Type or select pen size (1-200)",
|
||||||
|
style,
|
||||||
|
size
|
||||||
|
}: PenSizeSelectorProps) => {
|
||||||
|
const combobox = useCombobox();
|
||||||
|
|
||||||
|
const penSizeOptions = ['1', '2', '3', '4', '5', '8', '10', '12', '15', '20'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
onOptionSubmit={(optionValue) => {
|
||||||
|
const penSize = parseInt(optionValue);
|
||||||
|
if (!isNaN(penSize)) {
|
||||||
|
onValueChange(penSize);
|
||||||
|
onInputChange(optionValue);
|
||||||
|
}
|
||||||
|
combobox.closeDropdown();
|
||||||
|
}}
|
||||||
|
store={combobox}
|
||||||
|
withinPortal={false}
|
||||||
|
>
|
||||||
|
<Combobox.Target>
|
||||||
|
<TextInput
|
||||||
|
placeholder={placeholder}
|
||||||
|
size={size}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
const inputVal = event.currentTarget.value;
|
||||||
|
onInputChange(inputVal);
|
||||||
|
|
||||||
|
const penSize = parseInt(inputVal);
|
||||||
|
if (!isNaN(penSize) && penSize >= 1 && penSize <= 200) {
|
||||||
|
onValueChange(penSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
combobox.openDropdown();
|
||||||
|
combobox.updateSelectedOptionIndex();
|
||||||
|
}}
|
||||||
|
onClick={() => combobox.openDropdown()}
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
onBlur={() => {
|
||||||
|
combobox.closeDropdown();
|
||||||
|
const penSize = parseInt(inputValue);
|
||||||
|
if (isNaN(penSize) || penSize < 1 || penSize > 200) {
|
||||||
|
onInputChange(value.toString());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Options>
|
||||||
|
{penSizeOptions.map((sizeOption) => (
|
||||||
|
<Combobox.Option value={sizeOption} key={sizeOption}>
|
||||||
|
{sizeOption}px
|
||||||
|
</Combobox.Option>
|
||||||
|
))}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PenSizeSelector;
|
||||||
259
frontend/src/components/tools/sign/SignSettings.tsx
Normal file
259
frontend/src/components/tools/sign/SignSettings.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Stack, Button, Text, Alert, Tabs } from '@mantine/core';
|
||||||
|
import { SignParameters } from "../../../hooks/tools/sign/useSignParameters";
|
||||||
|
import { SuggestedToolsSection } from "../shared/SuggestedToolsSection";
|
||||||
|
|
||||||
|
// Import the new reusable components
|
||||||
|
import { DrawingCanvas } from "../../annotation/shared/DrawingCanvas";
|
||||||
|
import { DrawingControls } from "../../annotation/shared/DrawingControls";
|
||||||
|
import { ImageUploader } from "../../annotation/shared/ImageUploader";
|
||||||
|
import { TextInputWithFont } from "../../annotation/shared/TextInputWithFont";
|
||||||
|
import { ColorPicker } from "../../annotation/shared/ColorPicker";
|
||||||
|
|
||||||
|
interface SignSettingsProps {
|
||||||
|
parameters: SignParameters;
|
||||||
|
onParameterChange: <K extends keyof SignParameters>(key: K, value: SignParameters[K]) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
onActivateDrawMode?: () => void;
|
||||||
|
onActivateSignaturePlacement?: () => void;
|
||||||
|
onDeactivateSignature?: () => void;
|
||||||
|
onUpdateDrawSettings?: (color: string, size: number) => void;
|
||||||
|
onUndo?: () => void;
|
||||||
|
onRedo?: () => void;
|
||||||
|
onSave?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignSettings = ({
|
||||||
|
parameters,
|
||||||
|
onParameterChange,
|
||||||
|
disabled = false,
|
||||||
|
onActivateSignaturePlacement,
|
||||||
|
onDeactivateSignature,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onSave
|
||||||
|
}: SignSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// State for drawing
|
||||||
|
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||||
|
const [penSize, setPenSize] = useState(2);
|
||||||
|
const [penSizeInput, setPenSizeInput] = useState('2');
|
||||||
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
// State for different signature types
|
||||||
|
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
|
||||||
|
const [imageSignatureData, setImageSignatureData] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Handle image upload
|
||||||
|
const handleImageChange = async (file: File | null) => {
|
||||||
|
if (file && !disabled) {
|
||||||
|
try {
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
if (e.target?.result) {
|
||||||
|
resolve(e.target.result as string);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear any existing canvas signatures when uploading image
|
||||||
|
setCanvasSignatureData(null);
|
||||||
|
setImageSignatureData(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading file:', error);
|
||||||
|
}
|
||||||
|
} else if (!file) {
|
||||||
|
setImageSignatureData(null);
|
||||||
|
if (onDeactivateSignature) {
|
||||||
|
onDeactivateSignature();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle signature data changes
|
||||||
|
const handleCanvasSignatureChange = (data: string | null) => {
|
||||||
|
setCanvasSignatureData(prev => {
|
||||||
|
if (prev === data) return prev; // Prevent unnecessary updates
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
if (data) {
|
||||||
|
// Clear image data when canvas is used
|
||||||
|
setImageSignatureData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle signature mode deactivation when switching types
|
||||||
|
useEffect(() => {
|
||||||
|
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
|
||||||
|
onDeactivateSignature();
|
||||||
|
}
|
||||||
|
}, [parameters.signatureType]);
|
||||||
|
|
||||||
|
// Handle text signature activation
|
||||||
|
useEffect(() => {
|
||||||
|
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
|
||||||
|
if (onActivateSignaturePlacement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onActivateSignaturePlacement();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
|
||||||
|
if (onDeactivateSignature) {
|
||||||
|
onDeactivateSignature();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [parameters.signatureType, parameters.signerName, onActivateSignaturePlacement, onDeactivateSignature]);
|
||||||
|
|
||||||
|
// Handle signature data updates
|
||||||
|
useEffect(() => {
|
||||||
|
let newSignatureData: string | undefined = undefined;
|
||||||
|
|
||||||
|
if (parameters.signatureType === 'image' && imageSignatureData) {
|
||||||
|
newSignatureData = imageSignatureData;
|
||||||
|
} else if (parameters.signatureType === 'canvas' && canvasSignatureData) {
|
||||||
|
newSignatureData = canvasSignatureData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if the signature data has actually changed
|
||||||
|
if (parameters.signatureData !== newSignatureData) {
|
||||||
|
onParameterChange('signatureData', newSignatureData);
|
||||||
|
}
|
||||||
|
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData]);
|
||||||
|
|
||||||
|
// Handle image signature activation - activate when image data syncs with parameters
|
||||||
|
useEffect(() => {
|
||||||
|
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onActivateSignaturePlacement();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
|
||||||
|
|
||||||
|
// Draw settings are no longer needed since draw mode is removed
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Signature Type Selection */}
|
||||||
|
<Tabs
|
||||||
|
value={parameters.signatureType}
|
||||||
|
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
|
||||||
|
>
|
||||||
|
<Tabs.List grow>
|
||||||
|
<Tabs.Tab value="canvas" style={{ fontSize: '0.8rem' }}>
|
||||||
|
{t('sign.type.canvas', 'Canvas')}
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="image" style={{ fontSize: '0.8rem' }}>
|
||||||
|
{t('sign.type.image', 'Image')}
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="text" style={{ fontSize: '0.8rem' }}>
|
||||||
|
{t('sign.type.text', 'Text')}
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Drawing Controls */}
|
||||||
|
<DrawingControls
|
||||||
|
onUndo={onUndo}
|
||||||
|
onRedo={onRedo}
|
||||||
|
onPlaceSignature={() => {
|
||||||
|
if (onActivateSignaturePlacement) {
|
||||||
|
onActivateSignaturePlacement();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
|
||||||
|
disabled={disabled}
|
||||||
|
showPlaceButton={false}
|
||||||
|
placeButtonText="Update and Place"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Signature Creation based on type */}
|
||||||
|
{parameters.signatureType === 'canvas' && (
|
||||||
|
<DrawingCanvas
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
penSize={penSize}
|
||||||
|
penSizeInput={penSizeInput}
|
||||||
|
onColorSwatchClick={() => setIsColorPickerOpen(true)}
|
||||||
|
onPenSizeChange={setPenSize}
|
||||||
|
onPenSizeInputChange={setPenSizeInput}
|
||||||
|
onSignatureDataChange={handleCanvasSignatureChange}
|
||||||
|
disabled={disabled}
|
||||||
|
additionalButtons={
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (onActivateSignaturePlacement) {
|
||||||
|
onActivateSignaturePlacement();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
color="blue"
|
||||||
|
variant="filled"
|
||||||
|
disabled={disabled || !canvasSignatureData}
|
||||||
|
>
|
||||||
|
Update and Place
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parameters.signatureType === 'image' && (
|
||||||
|
<ImageUploader
|
||||||
|
onImageChange={handleImageChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{parameters.signatureType === 'text' && (
|
||||||
|
<TextInputWithFont
|
||||||
|
text={parameters.signerName || ''}
|
||||||
|
onTextChange={(text) => onParameterChange('signerName', text)}
|
||||||
|
fontSize={parameters.fontSize || 16}
|
||||||
|
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
|
||||||
|
fontFamily={parameters.fontFamily || 'Helvetica'}
|
||||||
|
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Instructions for placing signature */}
|
||||||
|
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
|
||||||
|
<Text size="sm">
|
||||||
|
{parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'}
|
||||||
|
{parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'}
|
||||||
|
{parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Color Picker Modal */}
|
||||||
|
<ColorPicker
|
||||||
|
isOpen={isColorPickerOpen}
|
||||||
|
onClose={() => setIsColorPickerOpen(false)}
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
onColorChange={setSelectedColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Apply Signatures Button */}
|
||||||
|
{onSave && (
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
color="blue"
|
||||||
|
variant="filled"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('sign.applySignatures', 'Apply Signatures')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested Tools Section */}
|
||||||
|
<SuggestedToolsSection />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignSettings;
|
||||||
@ -9,6 +9,8 @@ import { useViewer } from "../../contexts/ViewerContext";
|
|||||||
import { LocalEmbedPDF } from './LocalEmbedPDF';
|
import { LocalEmbedPDF } from './LocalEmbedPDF';
|
||||||
import { PdfViewerToolbar } from './PdfViewerToolbar';
|
import { PdfViewerToolbar } from './PdfViewerToolbar';
|
||||||
import { ThumbnailSidebar } from './ThumbnailSidebar';
|
import { ThumbnailSidebar } from './ThumbnailSidebar';
|
||||||
|
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||||
|
import { useSignature } from '../../contexts/SignatureContext';
|
||||||
|
|
||||||
export interface EmbedPdfViewerProps {
|
export interface EmbedPdfViewerProps {
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
@ -33,6 +35,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
const zoomState = getZoomState();
|
const zoomState = getZoomState();
|
||||||
const spreadState = getSpreadState();
|
const spreadState = getSpreadState();
|
||||||
|
|
||||||
|
// Check if we're in signature mode
|
||||||
|
const { selectedTool } = useNavigationState();
|
||||||
|
const isSignatureMode = selectedTool === 'sign';
|
||||||
|
|
||||||
|
// Get signature context
|
||||||
|
const { signatureApiRef, historyApiRef } = useSignature();
|
||||||
|
|
||||||
// Get current file from FileContext
|
// Get current file from FileContext
|
||||||
const { selectors } = useFileState();
|
const { selectors } = useFileState();
|
||||||
@ -178,6 +186,13 @@ const EmbedPdfViewerContent = ({
|
|||||||
<LocalEmbedPDF
|
<LocalEmbedPDF
|
||||||
file={effectiveFile.file}
|
file={effectiveFile.file}
|
||||||
url={effectiveFile.url}
|
url={effectiveFile.url}
|
||||||
|
enableSignature={isSignatureMode}
|
||||||
|
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||||
|
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||||
|
onSignatureAdded={() => {
|
||||||
|
// Handle signature added - for debugging, enable console logs as needed
|
||||||
|
// Future: Handle signature completion
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
116
frontend/src/components/viewer/HistoryAPIBridge.tsx
Normal file
116
frontend/src/components/viewer/HistoryAPIBridge.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||||
|
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
|
||||||
|
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||||
|
import { useSignature } from '../../contexts/SignatureContext';
|
||||||
|
import { uuidV4 } from '@embedpdf/models';
|
||||||
|
|
||||||
|
export interface HistoryAPI {
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
canUndo: () => boolean;
|
||||||
|
canRedo: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
|
||||||
|
const { provides: historyApi } = useHistoryCapability();
|
||||||
|
const { provides: annotationApi } = useAnnotationCapability();
|
||||||
|
const { getImageData, storeImageData } = useSignature();
|
||||||
|
|
||||||
|
// Monitor annotation events to detect when annotations are restored
|
||||||
|
useEffect(() => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
const handleAnnotationEvent = (event: any) => {
|
||||||
|
const annotation = event.annotation;
|
||||||
|
|
||||||
|
// Store image data for all STAMP annotations immediately when created or modified
|
||||||
|
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
|
||||||
|
const storedImageData = getImageData(annotation.id);
|
||||||
|
if (!storedImageData || storedImageData !== annotation.imageSrc) {
|
||||||
|
storeImageData(annotation.id, annotation.imageSrc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle annotation restoration after undo operations
|
||||||
|
if (event.type === 'create' && event.committed) {
|
||||||
|
// Check if this is a STAMP annotation (signature) that might need image data restoration
|
||||||
|
if (annotation && annotation.type === 13 && annotation.id) {
|
||||||
|
getImageData(annotation.id);
|
||||||
|
|
||||||
|
// Delay the check to allow the annotation to be fully created
|
||||||
|
setTimeout(() => {
|
||||||
|
const currentStoredData = getImageData(annotation.id);
|
||||||
|
// Check if the annotation lacks image data but we have it stored
|
||||||
|
if (currentStoredData && (!annotation.imageSrc || annotation.imageSrc !== currentStoredData)) {
|
||||||
|
|
||||||
|
// Generate new ID to avoid React key conflicts
|
||||||
|
const newId = uuidV4();
|
||||||
|
|
||||||
|
// Recreation with stored image data
|
||||||
|
const restoredData = {
|
||||||
|
type: annotation.type,
|
||||||
|
rect: annotation.rect,
|
||||||
|
author: annotation.author || 'Digital Signature',
|
||||||
|
subject: annotation.subject || 'Digital Signature',
|
||||||
|
pageIndex: event.pageIndex,
|
||||||
|
id: newId,
|
||||||
|
created: annotation.created || new Date(),
|
||||||
|
imageSrc: currentStoredData
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update stored data to use new ID
|
||||||
|
storeImageData(newId, currentStoredData);
|
||||||
|
|
||||||
|
// Replace the annotation with one that has proper image data
|
||||||
|
try {
|
||||||
|
annotationApi.deleteAnnotation(event.pageIndex, annotation.id);
|
||||||
|
// Small delay to ensure deletion completes
|
||||||
|
setTimeout(() => {
|
||||||
|
annotationApi.createAnnotation(event.pageIndex, restoredData);
|
||||||
|
}, 50);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HistoryAPI: Failed to restore annotation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the event listener
|
||||||
|
annotationApi.onAnnotationEvent(handleAnnotationEvent);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
// Note: EmbedPDF doesn't provide a way to remove event listeners
|
||||||
|
// This is a limitation of the current API
|
||||||
|
};
|
||||||
|
}, [annotationApi, getImageData, storeImageData]);
|
||||||
|
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
undo: () => {
|
||||||
|
if (historyApi) {
|
||||||
|
historyApi.undo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
if (historyApi) {
|
||||||
|
historyApi.redo();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
canUndo: () => {
|
||||||
|
return historyApi ? historyApi.canUndo() : false;
|
||||||
|
},
|
||||||
|
|
||||||
|
canRedo: () => {
|
||||||
|
return historyApi ? historyApi.canRedo() : false;
|
||||||
|
},
|
||||||
|
}), [historyApi]);
|
||||||
|
|
||||||
|
return null; // This is a bridge component with no UI
|
||||||
|
});
|
||||||
|
|
||||||
|
HistoryAPIBridge.displayName = 'HistoryAPIBridge';
|
||||||
@ -19,6 +19,11 @@ import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
|||||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||||
import { Rotation } from '@embedpdf/models';
|
import { Rotation } from '@embedpdf/models';
|
||||||
|
|
||||||
|
// Import annotation plugins
|
||||||
|
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||||
|
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||||
|
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||||
import { CustomSearchLayer } from './CustomSearchLayer';
|
import { CustomSearchLayer } from './CustomSearchLayer';
|
||||||
import { ZoomAPIBridge } from './ZoomAPIBridge';
|
import { ZoomAPIBridge } from './ZoomAPIBridge';
|
||||||
import ToolLoadingFallback from '../tools/ToolLoadingFallback';
|
import ToolLoadingFallback from '../tools/ToolLoadingFallback';
|
||||||
@ -30,15 +35,22 @@ import { SpreadAPIBridge } from './SpreadAPIBridge';
|
|||||||
import { SearchAPIBridge } from './SearchAPIBridge';
|
import { SearchAPIBridge } from './SearchAPIBridge';
|
||||||
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
|
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
|
||||||
import { RotateAPIBridge } from './RotateAPIBridge';
|
import { RotateAPIBridge } from './RotateAPIBridge';
|
||||||
|
import { SignatureAPIBridge, SignatureAPI } from './SignatureAPIBridge';
|
||||||
|
import { HistoryAPIBridge, HistoryAPI } from './HistoryAPIBridge';
|
||||||
import { ExportAPIBridge } from './ExportAPIBridge';
|
import { ExportAPIBridge } from './ExportAPIBridge';
|
||||||
|
|
||||||
interface LocalEmbedPDFProps {
|
interface LocalEmbedPDFProps {
|
||||||
file?: File | Blob;
|
file?: File | Blob;
|
||||||
url?: string | null;
|
url?: string | null;
|
||||||
|
enableSignature?: boolean;
|
||||||
|
onSignatureAdded?: (annotation: any) => void;
|
||||||
|
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||||
|
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||||
|
|
||||||
// Convert File to URL if needed
|
// Convert File to URL if needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -80,6 +92,17 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
|||||||
// Register selection plugin (depends on InteractionManager)
|
// Register selection plugin (depends on InteractionManager)
|
||||||
createPluginRegistration(SelectionPluginPackage),
|
createPluginRegistration(SelectionPluginPackage),
|
||||||
|
|
||||||
|
// Register history plugin for undo/redo (recommended for annotations)
|
||||||
|
...(enableSignature ? [createPluginRegistration(HistoryPluginPackage)] : []),
|
||||||
|
|
||||||
|
// Register annotation plugin (depends on InteractionManager, Selection, History)
|
||||||
|
...(enableSignature ? [createPluginRegistration(AnnotationPluginPackage, {
|
||||||
|
annotationAuthor: 'Digital Signature',
|
||||||
|
autoCommit: true,
|
||||||
|
deactivateToolAfterCreate: false,
|
||||||
|
selectAfterCreate: true,
|
||||||
|
})] : []),
|
||||||
|
|
||||||
// Register pan plugin (depends on Viewport, InteractionManager)
|
// Register pan plugin (depends on Viewport, InteractionManager)
|
||||||
createPluginRegistration(PanPluginPackage, {
|
createPluginRegistration(PanPluginPackage, {
|
||||||
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
defaultMode: 'mobile', // Try mobile mode which might be more permissive
|
||||||
@ -168,7 +191,72 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
|||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
minWidth: 0
|
minWidth: 0
|
||||||
}}>
|
}}>
|
||||||
<EmbedPDF engine={engine} plugins={plugins}>
|
<EmbedPDF
|
||||||
|
engine={engine}
|
||||||
|
plugins={plugins}
|
||||||
|
onInitialized={enableSignature ? async (registry) => {
|
||||||
|
const annotationPlugin = registry.getPlugin('annotation');
|
||||||
|
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||||
|
|
||||||
|
const annotationApi = annotationPlugin.provides();
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
// Add custom signature stamp tool for image signatures
|
||||||
|
annotationApi.addTool({
|
||||||
|
id: 'signatureStamp',
|
||||||
|
name: 'Digital Signature',
|
||||||
|
interaction: { exclusive: false, cursor: 'copy' },
|
||||||
|
matchScore: () => 0,
|
||||||
|
defaults: {
|
||||||
|
type: PdfAnnotationSubtype.STAMP,
|
||||||
|
// Image will be set dynamically when signature is created
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom ink signature tool for drawn signatures
|
||||||
|
annotationApi.addTool({
|
||||||
|
id: 'signatureInk',
|
||||||
|
name: 'Signature Draw',
|
||||||
|
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||||
|
matchScore: () => 0,
|
||||||
|
defaults: {
|
||||||
|
type: PdfAnnotationSubtype.INK,
|
||||||
|
color: '#000000',
|
||||||
|
opacity: 1.0,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for annotation events to track annotations and notify parent
|
||||||
|
annotationApi.onAnnotationEvent((event: any) => {
|
||||||
|
if (event.type === 'create' && event.committed) {
|
||||||
|
// Add to annotations list
|
||||||
|
setAnnotations(prev => [...prev, {
|
||||||
|
id: event.annotation.id,
|
||||||
|
pageIndex: event.pageIndex,
|
||||||
|
rect: event.annotation.rect
|
||||||
|
}]);
|
||||||
|
|
||||||
|
|
||||||
|
// Notify parent if callback provided
|
||||||
|
if (onSignatureAdded) {
|
||||||
|
onSignatureAdded(event.annotation);
|
||||||
|
}
|
||||||
|
} else if (event.type === 'delete' && event.committed) {
|
||||||
|
// Remove from annotations list
|
||||||
|
setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
|
||||||
|
} else if (event.type === 'loaded') {
|
||||||
|
// Handle initial load of annotations
|
||||||
|
const loadedAnnotations = event.annotations || [];
|
||||||
|
setAnnotations(loadedAnnotations.map((ann: any) => ({
|
||||||
|
id: ann.id,
|
||||||
|
pageIndex: ann.pageIndex || 0,
|
||||||
|
rect: ann.rect
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} : undefined}
|
||||||
|
>
|
||||||
<ZoomAPIBridge />
|
<ZoomAPIBridge />
|
||||||
<ScrollAPIBridge />
|
<ScrollAPIBridge />
|
||||||
<SelectionAPIBridge />
|
<SelectionAPIBridge />
|
||||||
@ -177,6 +265,8 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
|||||||
<SearchAPIBridge />
|
<SearchAPIBridge />
|
||||||
<ThumbnailAPIBridge />
|
<ThumbnailAPIBridge />
|
||||||
<RotateAPIBridge />
|
<RotateAPIBridge />
|
||||||
|
{enableSignature && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||||
|
{enableSignature && <HistoryAPIBridge ref={historyApiRef} />}
|
||||||
<ExportAPIBridge />
|
<ExportAPIBridge />
|
||||||
<GlobalPointerProvider>
|
<GlobalPointerProvider>
|
||||||
<Viewport
|
<Viewport
|
||||||
@ -221,6 +311,17 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
|||||||
|
|
||||||
{/* Selection layer for text interaction */}
|
{/* Selection layer for text interaction */}
|
||||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||||
|
{/* Annotation layer for signatures (only when enabled) */}
|
||||||
|
{enableSignature && (
|
||||||
|
<AnnotationLayer
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
scale={scale}
|
||||||
|
pageWidth={width}
|
||||||
|
pageHeight={height}
|
||||||
|
rotation={rotation || 0}
|
||||||
|
selectionOutlineColor="#007ACC"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PagePointerProvider>
|
</PagePointerProvider>
|
||||||
</Rotate>
|
</Rotate>
|
||||||
|
|||||||
315
frontend/src/components/viewer/LocalEmbedPDFWithAnnotations.tsx
Normal file
315
frontend/src/components/viewer/LocalEmbedPDFWithAnnotations.tsx
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { createPluginRegistration } from '@embedpdf/core';
|
||||||
|
import { EmbedPDF } from '@embedpdf/core/react';
|
||||||
|
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||||
|
|
||||||
|
// Import the essential plugins
|
||||||
|
import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react';
|
||||||
|
import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
|
||||||
|
import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
|
||||||
|
import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
|
||||||
|
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
|
||||||
|
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
|
||||||
|
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
|
||||||
|
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
|
||||||
|
import { PanPluginPackage } from '@embedpdf/plugin-pan/react';
|
||||||
|
import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||||
|
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||||
|
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||||
|
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||||
|
import { Rotation } from '@embedpdf/models';
|
||||||
|
|
||||||
|
// Import annotation plugins
|
||||||
|
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||||
|
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
|
||||||
|
import { PdfAnnotationSubtype } from '@embedpdf/models';
|
||||||
|
|
||||||
|
import { CustomSearchLayer } from './CustomSearchLayer';
|
||||||
|
import { ZoomAPIBridge } from './ZoomAPIBridge';
|
||||||
|
import ToolLoadingFallback from '../tools/ToolLoadingFallback';
|
||||||
|
import { Center, Stack, Text } from '@mantine/core';
|
||||||
|
import { ScrollAPIBridge } from './ScrollAPIBridge';
|
||||||
|
import { SelectionAPIBridge } from './SelectionAPIBridge';
|
||||||
|
import { PanAPIBridge } from './PanAPIBridge';
|
||||||
|
import { SpreadAPIBridge } from './SpreadAPIBridge';
|
||||||
|
import { SearchAPIBridge } from './SearchAPIBridge';
|
||||||
|
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
|
||||||
|
import { RotateAPIBridge } from './RotateAPIBridge';
|
||||||
|
|
||||||
|
interface LocalEmbedPDFWithAnnotationsProps {
|
||||||
|
file?: File | Blob;
|
||||||
|
url?: string | null;
|
||||||
|
onAnnotationChange?: (annotations: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocalEmbedPDFWithAnnotations({
|
||||||
|
file,
|
||||||
|
url,
|
||||||
|
onAnnotationChange
|
||||||
|
}: LocalEmbedPDFWithAnnotationsProps) {
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Convert File to URL if needed
|
||||||
|
useEffect(() => {
|
||||||
|
if (file) {
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
setPdfUrl(objectUrl);
|
||||||
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
|
} else if (url) {
|
||||||
|
setPdfUrl(url);
|
||||||
|
}
|
||||||
|
}, [file, url]);
|
||||||
|
|
||||||
|
// Create plugins configuration with annotation support
|
||||||
|
const plugins = useMemo(() => {
|
||||||
|
if (!pdfUrl) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
createPluginRegistration(LoaderPluginPackage, {
|
||||||
|
loadingOptions: {
|
||||||
|
type: 'url',
|
||||||
|
pdfFile: {
|
||||||
|
id: 'stirling-pdf-signing-viewer',
|
||||||
|
url: pdfUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createPluginRegistration(ViewportPluginPackage, {
|
||||||
|
viewportGap: 10,
|
||||||
|
}),
|
||||||
|
createPluginRegistration(ScrollPluginPackage, {
|
||||||
|
strategy: ScrollStrategy.Vertical,
|
||||||
|
initialPage: 0,
|
||||||
|
}),
|
||||||
|
createPluginRegistration(RenderPluginPackage),
|
||||||
|
|
||||||
|
// Register interaction manager (required for annotations)
|
||||||
|
createPluginRegistration(InteractionManagerPluginPackage),
|
||||||
|
|
||||||
|
// Register selection plugin (depends on InteractionManager)
|
||||||
|
createPluginRegistration(SelectionPluginPackage),
|
||||||
|
|
||||||
|
// Register history plugin for undo/redo (recommended for annotations)
|
||||||
|
createPluginRegistration(HistoryPluginPackage),
|
||||||
|
|
||||||
|
// Register annotation plugin (depends on InteractionManager, Selection, History)
|
||||||
|
createPluginRegistration(AnnotationPluginPackage, {
|
||||||
|
annotationAuthor: 'Digital Signature',
|
||||||
|
autoCommit: true,
|
||||||
|
deactivateToolAfterCreate: false,
|
||||||
|
selectAfterCreate: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Register pan plugin
|
||||||
|
createPluginRegistration(PanPluginPackage, {
|
||||||
|
defaultMode: 'mobile',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Register zoom plugin
|
||||||
|
createPluginRegistration(ZoomPluginPackage, {
|
||||||
|
defaultZoomLevel: 1.4,
|
||||||
|
minZoom: 0.2,
|
||||||
|
maxZoom: 3.0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Register tiling plugin
|
||||||
|
createPluginRegistration(TilingPluginPackage, {
|
||||||
|
tileSize: 768,
|
||||||
|
overlapPx: 5,
|
||||||
|
extraRings: 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Register spread plugin
|
||||||
|
createPluginRegistration(SpreadPluginPackage, {
|
||||||
|
defaultSpreadMode: SpreadMode.None,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Register search plugin
|
||||||
|
createPluginRegistration(SearchPluginPackage),
|
||||||
|
|
||||||
|
// Register thumbnail plugin
|
||||||
|
createPluginRegistration(ThumbnailPluginPackage),
|
||||||
|
|
||||||
|
// Register rotate plugin
|
||||||
|
createPluginRegistration(RotatePluginPackage, {
|
||||||
|
defaultRotation: Rotation.Degree0,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}, [pdfUrl]);
|
||||||
|
|
||||||
|
// Initialize the engine
|
||||||
|
const { engine, isLoading, error } = usePdfiumEngine();
|
||||||
|
|
||||||
|
// Early return if no file or URL provided
|
||||||
|
if (!file && !url) {
|
||||||
|
return (
|
||||||
|
<Center h="100%" w="100%">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<div style={{ fontSize: '24px' }}>📄</div>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
No PDF provided
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !engine || !pdfUrl) {
|
||||||
|
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Center h="100%" w="100%">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<div style={{ fontSize: '24px' }}>❌</div>
|
||||||
|
<Text c="red" size="sm" style={{ textAlign: 'center' }}>
|
||||||
|
Error loading PDF engine: {error.message}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0
|
||||||
|
}}>
|
||||||
|
<EmbedPDF
|
||||||
|
engine={engine}
|
||||||
|
plugins={plugins}
|
||||||
|
onInitialized={async (registry) => {
|
||||||
|
const annotationPlugin = registry.getPlugin('annotation');
|
||||||
|
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||||
|
|
||||||
|
const annotationApi = annotationPlugin.provides();
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
// Add custom signature stamp tool
|
||||||
|
annotationApi.addTool({
|
||||||
|
id: 'signatureStamp',
|
||||||
|
name: 'Digital Signature',
|
||||||
|
interaction: { exclusive: false, cursor: 'copy' },
|
||||||
|
matchScore: () => 0,
|
||||||
|
defaults: {
|
||||||
|
type: PdfAnnotationSubtype.STAMP,
|
||||||
|
// Will be set dynamically when user creates signature
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom ink signature tool
|
||||||
|
annotationApi.addTool({
|
||||||
|
id: 'signatureInk',
|
||||||
|
name: 'Signature Draw',
|
||||||
|
interaction: { exclusive: true, cursor: 'crosshair' },
|
||||||
|
matchScore: () => 0,
|
||||||
|
defaults: {
|
||||||
|
type: PdfAnnotationSubtype.INK,
|
||||||
|
color: '#000000',
|
||||||
|
opacity: 1.0,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for annotation events to notify parent
|
||||||
|
if (onAnnotationChange) {
|
||||||
|
annotationApi.onAnnotationEvent((event: any) => {
|
||||||
|
if (event.committed) {
|
||||||
|
// Get all annotations and notify parent
|
||||||
|
// This is a simplified approach - in reality you'd need to get all annotations
|
||||||
|
onAnnotationChange([event.annotation]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ZoomAPIBridge />
|
||||||
|
<ScrollAPIBridge />
|
||||||
|
<SelectionAPIBridge />
|
||||||
|
<PanAPIBridge />
|
||||||
|
<SpreadAPIBridge />
|
||||||
|
<SearchAPIBridge />
|
||||||
|
<ThumbnailAPIBridge />
|
||||||
|
<RotateAPIBridge />
|
||||||
|
<GlobalPointerProvider>
|
||||||
|
<Viewport
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--bg-surface)',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
contain: 'strict',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Scroller
|
||||||
|
renderPage={({ width, height, pageIndex, scale, rotation }: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pageIndex: number;
|
||||||
|
scale: number;
|
||||||
|
rotation?: number;
|
||||||
|
}) => (
|
||||||
|
<Rotate pageSize={{ width, height }}>
|
||||||
|
<PagePointerProvider {...{
|
||||||
|
pageWidth: width,
|
||||||
|
pageHeight: height,
|
||||||
|
pageIndex,
|
||||||
|
scale,
|
||||||
|
rotation: rotation || 0
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
MozUserSelect: 'none',
|
||||||
|
msUserSelect: 'none'
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
onDragStart={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => e.preventDefault()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{/* High-resolution tile layer */}
|
||||||
|
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||||
|
|
||||||
|
{/* Search highlight layer */}
|
||||||
|
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||||
|
|
||||||
|
{/* Selection layer for text interaction */}
|
||||||
|
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||||
|
|
||||||
|
{/* Annotation layer for signatures */}
|
||||||
|
<AnnotationLayer
|
||||||
|
pageIndex={pageIndex}
|
||||||
|
scale={scale}
|
||||||
|
pageWidth={width}
|
||||||
|
pageHeight={height}
|
||||||
|
rotation={rotation || 0}
|
||||||
|
selectionOutlineColor="#007ACC"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PagePointerProvider>
|
||||||
|
</Rotate>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Viewport>
|
||||||
|
</GlobalPointerProvider>
|
||||||
|
</EmbedPDF>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
frontend/src/components/viewer/SignatureAPIBridge.tsx
Normal file
370
frontend/src/components/viewer/SignatureAPIBridge.tsx
Normal file
@ -0,0 +1,370 @@
|
|||||||
|
import { useImperativeHandle, forwardRef, useEffect } from 'react';
|
||||||
|
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||||
|
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
|
||||||
|
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
|
||||||
|
import { useSignature } from '../../contexts/SignatureContext';
|
||||||
|
|
||||||
|
export interface SignatureAPI {
|
||||||
|
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
|
||||||
|
addTextSignature: (text: string, x: number, y: number, pageIndex: number) => void;
|
||||||
|
activateDrawMode: () => void;
|
||||||
|
activateSignaturePlacementMode: () => void;
|
||||||
|
activateDeleteMode: () => void;
|
||||||
|
deleteAnnotation: (annotationId: string, pageIndex: number) => void;
|
||||||
|
updateDrawSettings: (color: string, size: number) => void;
|
||||||
|
deactivateTools: () => void;
|
||||||
|
applySignatureFromParameters: (params: SignParameters) => void;
|
||||||
|
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||||
|
const { provides: annotationApi } = useAnnotationCapability();
|
||||||
|
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||||
|
|
||||||
|
|
||||||
|
// Enable keyboard deletion of selected annotations - only when in signature placement mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!annotationApi || !isPlacementMode) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||||
|
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
|
||||||
|
|
||||||
|
if (selectedAnnotation) {
|
||||||
|
const annotation = selectedAnnotation as any;
|
||||||
|
const pageIndex = annotation.object?.pageIndex || 0;
|
||||||
|
const id = annotation.object?.id;
|
||||||
|
|
||||||
|
// For STAMP annotations, ensure image data is preserved before deletion
|
||||||
|
if (annotation.object?.type === 13 && id) {
|
||||||
|
// Get current annotation data to ensure we have latest image data stored
|
||||||
|
const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex });
|
||||||
|
if (pageAnnotationsTask) {
|
||||||
|
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||||
|
const currentAnn = pageAnnotations?.find((ann: any) => ann.id === id);
|
||||||
|
if (currentAnn && currentAnn.imageSrc) {
|
||||||
|
// Ensure the image data is stored in our persistent store
|
||||||
|
storeImageData(id, currentAnn.imageSrc);
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use EmbedPDF's native deletion which should integrate with history
|
||||||
|
if ((annotationApi as any).deleteSelected) {
|
||||||
|
(annotationApi as any).deleteSelected();
|
||||||
|
} else {
|
||||||
|
// Fallback to direct deletion - less ideal for history
|
||||||
|
if (id) {
|
||||||
|
annotationApi.deleteAnnotation(pageIndex, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [annotationApi, storeImageData, isPlacementMode]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
// Create image stamp annotation with proper image data
|
||||||
|
|
||||||
|
const annotationId = uuidV4();
|
||||||
|
|
||||||
|
// Store image data in our persistent store
|
||||||
|
storeImageData(annotationId, signatureData);
|
||||||
|
|
||||||
|
annotationApi.createAnnotation(pageIndex, {
|
||||||
|
type: PdfAnnotationSubtype.STAMP,
|
||||||
|
rect: {
|
||||||
|
origin: { x, y },
|
||||||
|
size: { width, height }
|
||||||
|
},
|
||||||
|
author: 'Digital Signature',
|
||||||
|
subject: 'Digital Signature',
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
id: annotationId,
|
||||||
|
created: new Date(),
|
||||||
|
// Store image data in multiple places to ensure history captures it
|
||||||
|
imageSrc: signatureData,
|
||||||
|
contents: signatureData, // Some annotation systems use contents
|
||||||
|
data: signatureData, // Try data field
|
||||||
|
imageData: signatureData, // Try imageData field
|
||||||
|
appearance: signatureData // Try appearance field
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addTextSignature: (text: string, x: number, y: number, pageIndex: number) => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
// Create text annotation for signature
|
||||||
|
annotationApi.createAnnotation(pageIndex, {
|
||||||
|
type: PdfAnnotationSubtype.FREETEXT,
|
||||||
|
rect: {
|
||||||
|
origin: { x, y },
|
||||||
|
size: { width: 200, height: 50 }
|
||||||
|
},
|
||||||
|
contents: text,
|
||||||
|
author: 'Digital Signature',
|
||||||
|
fontSize: 16,
|
||||||
|
fontColor: '#000000',
|
||||||
|
fontFamily: PdfStandardFont.Helvetica,
|
||||||
|
textAlign: PdfTextAlignment.Left,
|
||||||
|
verticalAlign: PdfVerticalAlignment.Top,
|
||||||
|
opacity: 1,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
id: uuidV4(),
|
||||||
|
created: new Date(),
|
||||||
|
customData: {
|
||||||
|
signatureText: text,
|
||||||
|
signatureType: 'text'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
activateDrawMode: () => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
// Activate the built-in ink tool for drawing
|
||||||
|
annotationApi.setActiveTool('ink');
|
||||||
|
|
||||||
|
// Set default ink tool properties (black color, 2px width)
|
||||||
|
const activeTool = annotationApi.getActiveTool();
|
||||||
|
if (activeTool && activeTool.id === 'ink') {
|
||||||
|
annotationApi.setToolDefaults('ink', {
|
||||||
|
color: '#000000',
|
||||||
|
thickness: 2,
|
||||||
|
lineWidth: 2,
|
||||||
|
strokeWidth: 2,
|
||||||
|
width: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
activateSignaturePlacementMode: () => {
|
||||||
|
if (!annotationApi || !signatureConfig) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
|
||||||
|
// Try different tool names for text annotations
|
||||||
|
const textToolNames = ['freetext', 'text', 'textbox', 'annotation-text'];
|
||||||
|
let activatedTool = null;
|
||||||
|
|
||||||
|
for (const toolName of textToolNames) {
|
||||||
|
annotationApi.setActiveTool(toolName);
|
||||||
|
const tool = annotationApi.getActiveTool();
|
||||||
|
|
||||||
|
if (tool && tool.id === toolName) {
|
||||||
|
activatedTool = tool;
|
||||||
|
annotationApi.setToolDefaults(toolName, {
|
||||||
|
contents: signatureConfig.signerName,
|
||||||
|
fontSize: signatureConfig.fontSize || 16,
|
||||||
|
fontFamily: signatureConfig.fontFamily === 'Times-Roman' ? PdfStandardFont.Times_Roman :
|
||||||
|
signatureConfig.fontFamily === 'Courier' ? PdfStandardFont.Courier :
|
||||||
|
PdfStandardFont.Helvetica,
|
||||||
|
fontColor: '#000000',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activatedTool) {
|
||||||
|
// Fallback: create a simple text image as stamp
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
const fontSize = signatureConfig.fontSize || 16;
|
||||||
|
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
|
||||||
|
|
||||||
|
canvas.width = Math.max(200, signatureConfig.signerName.length * fontSize * 0.6);
|
||||||
|
canvas.height = fontSize + 20;
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
|
||||||
|
const dataURL = canvas.toDataURL();
|
||||||
|
|
||||||
|
annotationApi.setActiveTool('stamp');
|
||||||
|
const stampTool = annotationApi.getActiveTool();
|
||||||
|
if (stampTool && stampTool.id === 'stamp') {
|
||||||
|
annotationApi.setToolDefaults('stamp', {
|
||||||
|
imageSrc: dataURL,
|
||||||
|
subject: `Text Signature - ${signatureConfig.signerName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (signatureConfig.signatureData) {
|
||||||
|
// Use stamp tool for image/canvas signatures
|
||||||
|
annotationApi.setActiveTool('stamp');
|
||||||
|
const activeTool = annotationApi.getActiveTool();
|
||||||
|
if (activeTool && activeTool.id === 'stamp') {
|
||||||
|
annotationApi.setToolDefaults('stamp', {
|
||||||
|
imageSrc: signatureConfig.signatureData,
|
||||||
|
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error activating signature tool:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateDrawSettings: (color: string, size: number) => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
// Always update ink tool defaults - use multiple property names for compatibility
|
||||||
|
annotationApi.setToolDefaults('ink', {
|
||||||
|
color: color,
|
||||||
|
thickness: size,
|
||||||
|
lineWidth: size,
|
||||||
|
strokeWidth: size,
|
||||||
|
width: size
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force reactivate ink tool to ensure new settings take effect
|
||||||
|
annotationApi.setActiveTool(null); // Deactivate first
|
||||||
|
setTimeout(() => {
|
||||||
|
annotationApi.setActiveTool('ink'); // Reactivate with new settings
|
||||||
|
}, 50);
|
||||||
|
},
|
||||||
|
|
||||||
|
activateDeleteMode: () => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
// Activate selection tool to allow selecting and deleting annotations
|
||||||
|
// Users can click annotations to select them, then press Delete key or right-click to delete
|
||||||
|
annotationApi.setActiveTool('select');
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAnnotation: (annotationId: string, pageIndex: number) => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
|
||||||
|
// Before deleting, try to preserve image data for potential undo
|
||||||
|
const pageAnnotationsTask = annotationApi.getPageAnnotations?.({ pageIndex });
|
||||||
|
if (pageAnnotationsTask) {
|
||||||
|
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
|
||||||
|
const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId);
|
||||||
|
if (annotation && annotation.type === 13 && annotation.imageSrc) {
|
||||||
|
// Store image data before deletion
|
||||||
|
storeImageData(annotationId, annotation.imageSrc);
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete specific annotation by ID
|
||||||
|
annotationApi.deleteAnnotation(pageIndex, annotationId);
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivateTools: () => {
|
||||||
|
if (!annotationApi) return;
|
||||||
|
annotationApi.setActiveTool(null);
|
||||||
|
},
|
||||||
|
|
||||||
|
applySignatureFromParameters: (params: SignParameters) => {
|
||||||
|
if (!annotationApi || !params.signaturePosition) return;
|
||||||
|
|
||||||
|
const { x, y, width, height, page } = params.signaturePosition;
|
||||||
|
|
||||||
|
switch (params.signatureType) {
|
||||||
|
case 'image':
|
||||||
|
if (params.signatureData) {
|
||||||
|
const annotationId = uuidV4();
|
||||||
|
|
||||||
|
// Store image data in our persistent store
|
||||||
|
storeImageData(annotationId, params.signatureData);
|
||||||
|
|
||||||
|
annotationApi.createAnnotation(page, {
|
||||||
|
type: PdfAnnotationSubtype.STAMP,
|
||||||
|
rect: {
|
||||||
|
origin: { x, y },
|
||||||
|
size: { width, height }
|
||||||
|
},
|
||||||
|
author: 'Digital Signature',
|
||||||
|
subject: `Digital Signature - ${params.reason || 'Document signing'}`,
|
||||||
|
pageIndex: page,
|
||||||
|
id: annotationId,
|
||||||
|
created: new Date(),
|
||||||
|
// Store image data in multiple places to ensure history captures it
|
||||||
|
imageSrc: params.signatureData,
|
||||||
|
contents: params.signatureData, // Some annotation systems use contents
|
||||||
|
data: params.signatureData, // Try data field
|
||||||
|
imageData: params.signatureData, // Try imageData field
|
||||||
|
appearance: params.signatureData // Try appearance field
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to select mode after placing signature so it can be easily deleted
|
||||||
|
setTimeout(() => {
|
||||||
|
annotationApi.setActiveTool('select');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
if (params.signerName) {
|
||||||
|
annotationApi.createAnnotation(page, {
|
||||||
|
type: PdfAnnotationSubtype.FREETEXT,
|
||||||
|
rect: {
|
||||||
|
origin: { x, y },
|
||||||
|
size: { width, height }
|
||||||
|
},
|
||||||
|
contents: params.signerName,
|
||||||
|
author: 'Digital Signature',
|
||||||
|
fontSize: 16,
|
||||||
|
fontColor: '#000000',
|
||||||
|
fontFamily: PdfStandardFont.Helvetica,
|
||||||
|
textAlign: PdfTextAlignment.Left,
|
||||||
|
verticalAlign: PdfVerticalAlignment.Top,
|
||||||
|
opacity: 1,
|
||||||
|
pageIndex: page,
|
||||||
|
id: uuidV4(),
|
||||||
|
created: new Date(),
|
||||||
|
customData: {
|
||||||
|
signatureText: params.signerName,
|
||||||
|
signatureType: 'text'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to select mode after placing signature so it can be easily deleted
|
||||||
|
setTimeout(() => {
|
||||||
|
annotationApi.setActiveTool('select');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'draw':
|
||||||
|
// For draw mode, we activate the tool and let user draw
|
||||||
|
annotationApi.setActiveTool('ink');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getPageAnnotations: async (pageIndex: number): Promise<any[]> => {
|
||||||
|
if (!annotationApi || !annotationApi.getPageAnnotations) {
|
||||||
|
console.warn('getPageAnnotations not available');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pageAnnotationsTask = annotationApi.getPageAnnotations({ pageIndex });
|
||||||
|
if (pageAnnotationsTask && pageAnnotationsTask.toPromise) {
|
||||||
|
const annotations = await pageAnnotationsTask.toPromise();
|
||||||
|
return annotations || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting annotations for page ${pageIndex}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}), [annotationApi, signatureConfig]);
|
||||||
|
|
||||||
|
|
||||||
|
return null; // This is a bridge component with no UI
|
||||||
|
});
|
||||||
|
|
||||||
|
SignatureAPIBridge.displayName = 'SignatureAPIBridge';
|
||||||
@ -25,7 +25,7 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSide
|
|||||||
});
|
});
|
||||||
setThumbnails({});
|
setThumbnails({});
|
||||||
}
|
}
|
||||||
}, [visible, thumbnails]);
|
}, [visible]); // Remove thumbnails from dependency to prevent infinite loop
|
||||||
|
|
||||||
// Generate thumbnails when sidebar becomes visible
|
// Generate thumbnails when sidebar becomes visible
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
178
frontend/src/contexts/SignatureContext.tsx
Normal file
178
frontend/src/contexts/SignatureContext.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
|
||||||
|
import { SignParameters } from '../hooks/tools/sign/useSignParameters';
|
||||||
|
import { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
|
||||||
|
import { HistoryAPI } from '../components/viewer/HistoryAPIBridge';
|
||||||
|
|
||||||
|
// Signature state interface
|
||||||
|
interface SignatureState {
|
||||||
|
// Current signature configuration from the tool
|
||||||
|
signatureConfig: SignParameters | null;
|
||||||
|
// Whether we're in signature placement mode
|
||||||
|
isPlacementMode: boolean;
|
||||||
|
// Whether signatures have been applied (allows export)
|
||||||
|
signaturesApplied: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signature actions interface
|
||||||
|
interface SignatureActions {
|
||||||
|
setSignatureConfig: (config: SignParameters | null) => void;
|
||||||
|
setPlacementMode: (enabled: boolean) => void;
|
||||||
|
activateDrawMode: () => void;
|
||||||
|
deactivateDrawMode: () => void;
|
||||||
|
activateSignaturePlacementMode: () => void;
|
||||||
|
activateDeleteMode: () => void;
|
||||||
|
updateDrawSettings: (color: string, size: number) => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
storeImageData: (id: string, data: string) => void;
|
||||||
|
getImageData: (id: string) => string | undefined;
|
||||||
|
setSignaturesApplied: (applied: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined context interface
|
||||||
|
interface SignatureContextValue extends SignatureState, SignatureActions {
|
||||||
|
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||||
|
historyApiRef: React.RefObject<HistoryAPI | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context
|
||||||
|
const SignatureContext = createContext<SignatureContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: SignatureState = {
|
||||||
|
signatureConfig: null,
|
||||||
|
isPlacementMode: false,
|
||||||
|
signaturesApplied: true, // Start as true (no signatures placed yet)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provider component
|
||||||
|
export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [state, setState] = useState<SignatureState>(initialState);
|
||||||
|
const signatureApiRef = useRef<SignatureAPI>(null);
|
||||||
|
const historyApiRef = useRef<HistoryAPI>(null);
|
||||||
|
const imageDataStore = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const setSignatureConfig = useCallback((config: SignParameters | null) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
signatureConfig: config,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPlacementMode = useCallback((enabled: boolean) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isPlacementMode: enabled,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const activateDrawMode = useCallback(() => {
|
||||||
|
if (signatureApiRef.current) {
|
||||||
|
signatureApiRef.current.activateDrawMode();
|
||||||
|
setPlacementMode(true);
|
||||||
|
// Mark signatures as not applied when entering draw mode
|
||||||
|
setState(prev => ({ ...prev, signaturesApplied: false }));
|
||||||
|
}
|
||||||
|
}, [setPlacementMode]);
|
||||||
|
|
||||||
|
const deactivateDrawMode = useCallback(() => {
|
||||||
|
if (signatureApiRef.current) {
|
||||||
|
signatureApiRef.current.deactivateTools();
|
||||||
|
setPlacementMode(false);
|
||||||
|
}
|
||||||
|
}, [setPlacementMode]);
|
||||||
|
|
||||||
|
const activateSignaturePlacementMode = useCallback(() => {
|
||||||
|
if (signatureApiRef.current) {
|
||||||
|
signatureApiRef.current.activateSignaturePlacementMode();
|
||||||
|
setPlacementMode(true);
|
||||||
|
// Mark signatures as not applied when placing new signatures
|
||||||
|
setState(prev => ({ ...prev, signaturesApplied: false }));
|
||||||
|
}
|
||||||
|
}, [setPlacementMode]);
|
||||||
|
|
||||||
|
const activateDeleteMode = useCallback(() => {
|
||||||
|
if (signatureApiRef.current) {
|
||||||
|
signatureApiRef.current.activateDeleteMode();
|
||||||
|
setPlacementMode(true);
|
||||||
|
}
|
||||||
|
}, [setPlacementMode]);
|
||||||
|
|
||||||
|
const updateDrawSettings = useCallback((color: string, size: number) => {
|
||||||
|
if (signatureApiRef.current) {
|
||||||
|
signatureApiRef.current.updateDrawSettings(color, size);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (historyApiRef.current) {
|
||||||
|
historyApiRef.current.undo();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (historyApiRef.current) {
|
||||||
|
historyApiRef.current.redo();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const storeImageData = useCallback((id: string, data: string) => {
|
||||||
|
imageDataStore.current.set(id, data);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getImageData = useCallback((id: string) => {
|
||||||
|
return imageDataStore.current.get(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSignaturesApplied = useCallback((applied: boolean) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
signaturesApplied: applied,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// No auto-activation - all modes use manual buttons
|
||||||
|
|
||||||
|
const contextValue: SignatureContextValue = {
|
||||||
|
...state,
|
||||||
|
signatureApiRef,
|
||||||
|
historyApiRef,
|
||||||
|
setSignatureConfig,
|
||||||
|
setPlacementMode,
|
||||||
|
activateDrawMode,
|
||||||
|
deactivateDrawMode,
|
||||||
|
activateSignaturePlacementMode,
|
||||||
|
activateDeleteMode,
|
||||||
|
updateDrawSettings,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
storeImageData,
|
||||||
|
getImageData,
|
||||||
|
setSignaturesApplied,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SignatureContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SignatureContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook to use signature context
|
||||||
|
export const useSignature = (): SignatureContextValue => {
|
||||||
|
const context = useContext(SignatureContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSignature must be used within a SignatureProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for components that need to check if signature mode is active
|
||||||
|
export const useSignatureMode = () => {
|
||||||
|
const context = useContext(SignatureContext);
|
||||||
|
return {
|
||||||
|
isSignatureModeActive: context?.isPlacementMode || false,
|
||||||
|
hasSignatureConfig: context?.signatureConfig !== null,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
|
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
|
||||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||||
|
import { useNavigation } from './NavigationContext';
|
||||||
|
|
||||||
// Bridge API interfaces - these match what the bridges provide
|
// Bridge API interfaces - these match what the bridges provide
|
||||||
interface ScrollAPIWrapper {
|
interface ScrollAPIWrapper {
|
||||||
@ -208,6 +209,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
|||||||
// UI state - only state directly managed by this context
|
// UI state - only state directly managed by this context
|
||||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||||
|
|
||||||
|
// Get current navigation state to check if we're in sign mode
|
||||||
|
useNavigation();
|
||||||
|
|
||||||
// Bridge registry - bridges register their state and APIs here
|
// Bridge registry - bridges register their state and APIs here
|
||||||
const bridgeRefs = useRef({
|
const bridgeRefs = useRef({
|
||||||
scroll: null as BridgeRef<ScrollState, ScrollAPIWrapper> | null,
|
scroll: null as BridgeRef<ScrollState, ScrollAPIWrapper> | null,
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import Flatten from "../tools/Flatten";
|
|||||||
import Rotate from "../tools/Rotate";
|
import Rotate from "../tools/Rotate";
|
||||||
import ChangeMetadata from "../tools/ChangeMetadata";
|
import ChangeMetadata from "../tools/ChangeMetadata";
|
||||||
import Crop from "../tools/Crop";
|
import Crop from "../tools/Crop";
|
||||||
|
import Sign from "../tools/Sign";
|
||||||
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation";
|
||||||
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation";
|
||||||
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation";
|
||||||
@ -55,6 +56,7 @@ import { flattenOperationConfig } from "../hooks/tools/flatten/useFlattenOperati
|
|||||||
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
import { redactOperationConfig } from "../hooks/tools/redact/useRedactOperation";
|
||||||
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
import { rotateOperationConfig } from "../hooks/tools/rotate/useRotateOperation";
|
||||||
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
import { changeMetadataOperationConfig } from "../hooks/tools/changeMetadata/useChangeMetadataOperation";
|
||||||
|
import { signOperationConfig } from "../hooks/tools/sign/useSignOperation";
|
||||||
import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
|
import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
|
||||||
import { removeAnnotationsOperationConfig } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
import { removeAnnotationsOperationConfig } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
||||||
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
|
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
|
||||||
@ -86,6 +88,7 @@ import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSpl
|
|||||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||||
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||||
|
import SignSettings from "../components/tools/sign/SignSettings";
|
||||||
import CropSettings from "../components/tools/crop/CropSettings";
|
import CropSettings from "../components/tools/crop/CropSettings";
|
||||||
import RemoveAnnotations from "../tools/RemoveAnnotations";
|
import RemoveAnnotations from "../tools/RemoveAnnotations";
|
||||||
import RemoveAnnotationsSettings from "../components/tools/removeAnnotations/RemoveAnnotationsSettings";
|
import RemoveAnnotationsSettings from "../components/tools/removeAnnotations/RemoveAnnotationsSettings";
|
||||||
@ -225,10 +228,12 @@ export function useFlatToolRegistry(): ToolRegistry {
|
|||||||
sign: {
|
sign: {
|
||||||
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.sign.title", "Sign"),
|
name: t("home.sign.title", "Sign"),
|
||||||
component: null,
|
component: Sign,
|
||||||
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
|
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
|
||||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
subcategoryId: SubcategoryId.SIGNING,
|
subcategoryId: SubcategoryId.SIGNING,
|
||||||
|
operationConfig: signOperationConfig,
|
||||||
|
settingsComponent: SignSettings,
|
||||||
synonyms: getSynonyms(t, "sign")
|
synonyms: getSynonyms(t, "sign")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
1
frontend/src/global.d.ts
vendored
1
frontend/src/global.d.ts
vendored
@ -18,6 +18,5 @@ declare module '../assets/material-symbols-icons.json' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare module 'pdfjs-dist/legacy/build/pdf.mjs'
|
declare module 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||||
// TODO: Add proper EmbedPDF types for local submodule integration
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
59
frontend/src/hooks/tools/sign/useSignOperation.ts
Normal file
59
frontend/src/hooks/tools/sign/useSignOperation.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useToolOperation, ToolOperationHook, ToolType } from '../shared/useToolOperation';
|
||||||
|
import { SignParameters, DEFAULT_PARAMETERS } from './useSignParameters';
|
||||||
|
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||||
|
|
||||||
|
// Static configuration that can be used by both the hook and automation executor
|
||||||
|
export const buildSignFormData = (params: SignParameters, file: File): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileInput', file);
|
||||||
|
|
||||||
|
// Add signature data if available
|
||||||
|
if (params.signatureData) {
|
||||||
|
formData.append('signatureData', params.signatureData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature position and size
|
||||||
|
if (params.signaturePosition) {
|
||||||
|
formData.append('x', params.signaturePosition.x.toString());
|
||||||
|
formData.append('y', params.signaturePosition.y.toString());
|
||||||
|
formData.append('width', params.signaturePosition.width.toString());
|
||||||
|
formData.append('height', params.signaturePosition.height.toString());
|
||||||
|
formData.append('page', params.signaturePosition.page.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature type
|
||||||
|
formData.append('signatureType', params.signatureType || 'draw');
|
||||||
|
|
||||||
|
// Add other parameters
|
||||||
|
if (params.reason) {
|
||||||
|
formData.append('reason', params.reason);
|
||||||
|
}
|
||||||
|
if (params.location) {
|
||||||
|
formData.append('location', params.location);
|
||||||
|
}
|
||||||
|
if (params.signerName) {
|
||||||
|
formData.append('signerName', params.signerName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static configuration object
|
||||||
|
export const signOperationConfig = {
|
||||||
|
toolType: ToolType.singleFile,
|
||||||
|
buildFormData: buildSignFormData,
|
||||||
|
operationType: 'sign',
|
||||||
|
endpoint: '/api/v1/security/add-signature',
|
||||||
|
filePrefix: 'signed_',
|
||||||
|
defaultParameters: DEFAULT_PARAMETERS,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useSignOperation = (): ToolOperationHook<SignParameters> => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useToolOperation<SignParameters>({
|
||||||
|
...signOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
|
||||||
|
});
|
||||||
|
};
|
||||||
61
frontend/src/hooks/tools/sign/useSignParameters.ts
Normal file
61
frontend/src/hooks/tools/sign/useSignParameters.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useBaseParameters } from '../shared/useBaseParameters';
|
||||||
|
|
||||||
|
export interface SignaturePosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignParameters {
|
||||||
|
signatureType: 'image' | 'text' | 'draw' | 'canvas';
|
||||||
|
signatureData?: string; // Base64 encoded image or text content
|
||||||
|
signaturePosition?: SignaturePosition;
|
||||||
|
reason?: string;
|
||||||
|
location?: string;
|
||||||
|
signerName?: string;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PARAMETERS: SignParameters = {
|
||||||
|
signatureType: 'canvas',
|
||||||
|
reason: 'Document signing',
|
||||||
|
location: 'Digital',
|
||||||
|
signerName: '',
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateSignParameters = (parameters: SignParameters): boolean => {
|
||||||
|
// Basic validation
|
||||||
|
if (!parameters.signatureType) return false;
|
||||||
|
|
||||||
|
// If signature position is set, validate it
|
||||||
|
if (parameters.signaturePosition) {
|
||||||
|
const pos = parameters.signaturePosition;
|
||||||
|
if (pos.x < 0 || pos.y < 0 || pos.width <= 0 || pos.height <= 0 || pos.page < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For image and canvas signatures, require signature data
|
||||||
|
if ((parameters.signatureType === 'image' || parameters.signatureType === 'canvas') && !parameters.signatureData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// For text signatures, require signer name
|
||||||
|
if (parameters.signatureType === 'text' && !parameters.signerName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSignParameters = () => {
|
||||||
|
return useBaseParameters<SignParameters>({
|
||||||
|
defaultParameters: DEFAULT_PARAMETERS,
|
||||||
|
endpointName: 'add-signature',
|
||||||
|
validateFn: validateSignParameters,
|
||||||
|
});
|
||||||
|
};
|
||||||
176
frontend/src/tools/Sign.tsx
Normal file
176
frontend/src/tools/Sign.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
import { useSignParameters } from "../hooks/tools/sign/useSignParameters";
|
||||||
|
import { useSignOperation } from "../hooks/tools/sign/useSignOperation";
|
||||||
|
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||||
|
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||||
|
import SignSettings from "../components/tools/sign/SignSettings";
|
||||||
|
import { useNavigation } from "../contexts/NavigationContext";
|
||||||
|
import { useSignature } from "../contexts/SignatureContext";
|
||||||
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
|
import { useViewer } from "../contexts/ViewerContext";
|
||||||
|
import { flattenSignatures } from "../utils/signatureFlattening";
|
||||||
|
|
||||||
|
const Sign = (props: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { setWorkbench } = useNavigation();
|
||||||
|
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature();
|
||||||
|
const { consumeFiles, selectors } = useFileContext();
|
||||||
|
const { exportActions, getScrollState } = useViewer();
|
||||||
|
|
||||||
|
// Track which signature mode was active for reactivation after save
|
||||||
|
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
|
||||||
|
|
||||||
|
// Single handler that activates placement mode
|
||||||
|
const handleSignaturePlacement = useCallback(() => {
|
||||||
|
activateSignaturePlacementMode();
|
||||||
|
}, [activateSignaturePlacementMode]);
|
||||||
|
|
||||||
|
// Memoized callbacks for SignSettings to prevent infinite loops
|
||||||
|
const handleActivateDrawMode = useCallback(() => {
|
||||||
|
activeModeRef.current = 'draw';
|
||||||
|
activateDrawMode();
|
||||||
|
}, [activateDrawMode]);
|
||||||
|
|
||||||
|
const handleActivateSignaturePlacement = useCallback(() => {
|
||||||
|
activeModeRef.current = 'placement';
|
||||||
|
handleSignaturePlacement();
|
||||||
|
}, [handleSignaturePlacement]);
|
||||||
|
|
||||||
|
const base = useBaseTool(
|
||||||
|
'sign',
|
||||||
|
useSignParameters,
|
||||||
|
useSignOperation,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open viewer when files are selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (base.selectedFiles.length > 0) {
|
||||||
|
setWorkbench('viewer');
|
||||||
|
}
|
||||||
|
}, [base.selectedFiles.length, setWorkbench]);
|
||||||
|
|
||||||
|
|
||||||
|
// Sync signature configuration with context
|
||||||
|
useEffect(() => {
|
||||||
|
setSignatureConfig(base.params.parameters);
|
||||||
|
}, [base.params.parameters, setSignatureConfig]);
|
||||||
|
|
||||||
|
// Save signed files to the system - apply signatures using EmbedPDF and replace original
|
||||||
|
const handleSaveToSystem = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
// Get the original file
|
||||||
|
let originalFile = null;
|
||||||
|
if (base.selectedFiles.length > 0) {
|
||||||
|
originalFile = base.selectedFiles[0];
|
||||||
|
} else {
|
||||||
|
const allFileIds = selectors.getAllFileIds();
|
||||||
|
if (allFileIds.length > 0) {
|
||||||
|
const stirlingFile = selectors.getFile(allFileIds[0]);
|
||||||
|
if (stirlingFile) {
|
||||||
|
originalFile = stirlingFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!originalFile) {
|
||||||
|
console.error('No file available to replace');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the signature flattening utility
|
||||||
|
const success = await flattenSignatures({
|
||||||
|
signatureApiRef,
|
||||||
|
getImageData,
|
||||||
|
exportActions,
|
||||||
|
selectors,
|
||||||
|
consumeFiles,
|
||||||
|
originalFile,
|
||||||
|
getScrollState
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('✓ Signature flattening completed successfully');
|
||||||
|
|
||||||
|
// Mark signatures as applied
|
||||||
|
setSignaturesApplied(true);
|
||||||
|
|
||||||
|
// Force refresh the viewer to show the flattened PDF
|
||||||
|
setTimeout(() => {
|
||||||
|
// Navigate away from viewer and back to force reload
|
||||||
|
setWorkbench('fileEditor');
|
||||||
|
setTimeout(() => {
|
||||||
|
setWorkbench('viewer');
|
||||||
|
|
||||||
|
// Reactivate the signature mode that was active before save
|
||||||
|
if (activeModeRef.current === 'draw') {
|
||||||
|
activateDrawMode();
|
||||||
|
} else if (activeModeRef.current === 'placement') {
|
||||||
|
handleSignaturePlacement();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
console.error('Signature flattening failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving signed document:', error);
|
||||||
|
}
|
||||||
|
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode]);
|
||||||
|
|
||||||
|
const getSteps = () => {
|
||||||
|
const steps = [];
|
||||||
|
|
||||||
|
// Step 1: Signature Configuration - Always visible
|
||||||
|
steps.push({
|
||||||
|
title: t('sign.steps.configure', 'Configure Signature'),
|
||||||
|
isCollapsed: false,
|
||||||
|
onCollapsedClick: undefined,
|
||||||
|
content: (
|
||||||
|
<SignSettings
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
onParameterChange={base.params.updateParameter}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
onActivateDrawMode={handleActivateDrawMode}
|
||||||
|
onActivateSignaturePlacement={handleActivateSignaturePlacement}
|
||||||
|
onDeactivateSignature={deactivateDrawMode}
|
||||||
|
onUpdateDrawSettings={updateDrawSettings}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onSave={handleSaveToSystem}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
};
|
||||||
|
|
||||||
|
return createToolFlow({
|
||||||
|
files: {
|
||||||
|
selectedFiles: base.selectedFiles,
|
||||||
|
isCollapsed: base.operation.files.length > 0,
|
||||||
|
},
|
||||||
|
steps: getSteps(),
|
||||||
|
review: {
|
||||||
|
isVisible: false, // Hide review section - save moved to configure section
|
||||||
|
operation: base.operation,
|
||||||
|
title: t('sign.results.title', 'Signature Results'),
|
||||||
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: () => {},
|
||||||
|
},
|
||||||
|
forceStepNumbers: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the required static methods for automation
|
||||||
|
Sign.tool = () => useSignOperation;
|
||||||
|
Sign.getDefaultParameters = () => ({
|
||||||
|
signatureType: 'canvas',
|
||||||
|
reason: 'Document signing',
|
||||||
|
location: 'Digital',
|
||||||
|
signerName: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Sign as ToolComponent;
|
||||||
309
frontend/src/utils/signatureFlattening.ts
Normal file
309
frontend/src/utils/signatureFlattening.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { PDFDocument, rgb } from 'pdf-lib';
|
||||||
|
import { generateThumbnailWithMetadata } from './thumbnailUtils';
|
||||||
|
import { createProcessedFile } from '../contexts/file/fileActions';
|
||||||
|
import { createNewStirlingFileStub, createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
|
||||||
|
import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
|
||||||
|
|
||||||
|
interface MinimalFileContextSelectors {
|
||||||
|
getAllFileIds: () => FileId[];
|
||||||
|
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
|
||||||
|
getFile: (id: FileId) => StirlingFile | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignatureFlatteningOptions {
|
||||||
|
signatureApiRef: React.RefObject<SignatureAPI | null>;
|
||||||
|
getImageData: (id: string) => string | undefined;
|
||||||
|
exportActions?: {
|
||||||
|
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
||||||
|
};
|
||||||
|
selectors: MinimalFileContextSelectors;
|
||||||
|
consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise<FileId[]>;
|
||||||
|
originalFile?: StirlingFile;
|
||||||
|
getScrollState: () => { currentPage: number; totalPages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flattenSignatures(options: SignatureFlatteningOptions): Promise<boolean> {
|
||||||
|
const { signatureApiRef, getImageData, exportActions, selectors, consumeFiles, originalFile, getScrollState } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Extract all annotations from EmbedPDF before export
|
||||||
|
const allAnnotations: Array<{pageIndex: number, annotations: any[]}> = [];
|
||||||
|
|
||||||
|
if (signatureApiRef?.current) {
|
||||||
|
|
||||||
|
// Get actual page count from viewer
|
||||||
|
const scrollState = getScrollState();
|
||||||
|
const totalPages = scrollState.totalPages;
|
||||||
|
|
||||||
|
// Check only actual pages that exist in the document
|
||||||
|
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||||
|
try {
|
||||||
|
const pageAnnotations = await signatureApiRef.current.getPageAnnotations(pageIndex);
|
||||||
|
if (pageAnnotations && pageAnnotations.length > 0) {
|
||||||
|
// Filter to only include annotations added in this session
|
||||||
|
const sessionAnnotations = pageAnnotations.filter(annotation => {
|
||||||
|
// Check if this annotation has stored image data (indicates it was added this session)
|
||||||
|
const hasStoredImageData = annotation.id && getImageData(annotation.id);
|
||||||
|
|
||||||
|
// Also check if it has image data directly in the annotation (new signatures)
|
||||||
|
const hasDirectImageData = annotation.imageData || annotation.appearance ||
|
||||||
|
annotation.stampData || annotation.imageSrc ||
|
||||||
|
annotation.contents || annotation.data;
|
||||||
|
|
||||||
|
const isSessionAnnotation = hasStoredImageData || (hasDirectImageData && typeof hasDirectImageData === 'string' && hasDirectImageData.startsWith('data:image'));
|
||||||
|
|
||||||
|
|
||||||
|
return isSessionAnnotation;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessionAnnotations.length > 0) {
|
||||||
|
allAnnotations.push({pageIndex, annotations: sessionAnnotations});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (pageError) {
|
||||||
|
console.warn(`Error extracting annotations from page ${pageIndex + 1}:`, pageError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Total annotations found: ${allAnnotations.reduce((sum, page) => sum + page.annotations.length, 0)}`);
|
||||||
|
|
||||||
|
// Step 2: Delete ONLY session annotations from EmbedPDF before export (they'll be rendered manually)
|
||||||
|
// Leave old annotations alone - they will remain as annotations in the PDF
|
||||||
|
if (allAnnotations.length > 0 && signatureApiRef?.current) {
|
||||||
|
for (const pageData of allAnnotations) {
|
||||||
|
for (const annotation of pageData.annotations) {
|
||||||
|
try {
|
||||||
|
await signatureApiRef.current.deleteAnnotation(annotation.id, pageData.pageIndex);
|
||||||
|
} catch (deleteError) {
|
||||||
|
console.warn(`Failed to delete annotation ${annotation.id}:`, deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations)
|
||||||
|
if (!exportActions) {
|
||||||
|
console.error('No export actions available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pdfArrayBuffer = await exportActions.saveAsCopy();
|
||||||
|
|
||||||
|
if (pdfArrayBuffer) {
|
||||||
|
|
||||||
|
// Try loading with more permissive PDF-lib options
|
||||||
|
|
||||||
|
// Convert ArrayBuffer to File
|
||||||
|
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||||
|
|
||||||
|
// Get the current file - try from originalFile first, then from all files
|
||||||
|
let currentFile = originalFile;
|
||||||
|
if (!currentFile) {
|
||||||
|
const allFileIds = selectors.getAllFileIds();
|
||||||
|
if (allFileIds.length > 0) {
|
||||||
|
const fileStub = selectors.getStirlingFileStub(allFileIds[0]);
|
||||||
|
const fileObject = selectors.getFile(allFileIds[0]);
|
||||||
|
if (fileStub && fileObject) {
|
||||||
|
currentFile = createStirlingFile(fileObject, allFileIds[0] as FileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentFile) {
|
||||||
|
console.error('No file available to replace');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
|
||||||
|
|
||||||
|
// Step 4: Manually render extracted annotations onto the PDF using PDF-lib
|
||||||
|
if (allAnnotations.length > 0) {
|
||||||
|
try {
|
||||||
|
console.log('Manually rendering annotations onto PDF...');
|
||||||
|
const pdfArrayBufferForFlattening = await signedFile.arrayBuffer();
|
||||||
|
|
||||||
|
// Try different loading options to handle problematic PDFs
|
||||||
|
let pdfDoc: PDFDocument;
|
||||||
|
try {
|
||||||
|
pdfDoc = await PDFDocument.load(pdfArrayBufferForFlattening, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
capNumbers: false,
|
||||||
|
throwOnInvalidObject: false
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.warn('Failed to load with standard options, trying createProxy...');
|
||||||
|
try {
|
||||||
|
// Create a fresh PDF and copy pages instead of modifying
|
||||||
|
pdfDoc = await PDFDocument.create();
|
||||||
|
const sourcePdf = await PDFDocument.load(pdfArrayBufferForFlattening, {
|
||||||
|
ignoreEncryption: true,
|
||||||
|
throwOnInvalidObject: false
|
||||||
|
});
|
||||||
|
const pageIndices = sourcePdf.getPages().map((_, i) => i);
|
||||||
|
const copiedPages = await pdfDoc.copyPages(sourcePdf, pageIndices);
|
||||||
|
copiedPages.forEach(page => pdfDoc.addPage(page));
|
||||||
|
} catch (copyError) {
|
||||||
|
console.error('Failed to load PDF with any method:', copyError);
|
||||||
|
throw copyError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
|
||||||
|
|
||||||
|
for (const pageData of allAnnotations) {
|
||||||
|
const { pageIndex, annotations } = pageData;
|
||||||
|
|
||||||
|
if (pageIndex < pages.length) {
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
const { height: pageHeight } = page.getSize();
|
||||||
|
|
||||||
|
for (const annotation of annotations) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
|
||||||
|
|
||||||
|
if (rect) {
|
||||||
|
// Extract original annotation position and size
|
||||||
|
const originalX = rect.origin?.x || rect.x || rect.left || 0;
|
||||||
|
const originalY = rect.origin?.y || rect.y || rect.top || 0;
|
||||||
|
const width = rect.size?.width || rect.width || 100;
|
||||||
|
const height = rect.size?.height || rect.height || 50;
|
||||||
|
|
||||||
|
// Convert EmbedPDF coordinates to PDF-lib coordinates
|
||||||
|
const pdfX = originalX;
|
||||||
|
const pdfY = pageHeight - originalY - height;
|
||||||
|
|
||||||
|
|
||||||
|
// Try to get annotation image data
|
||||||
|
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
|
||||||
|
annotation.imageSrc || annotation.contents || annotation.data;
|
||||||
|
|
||||||
|
// If no image data found directly, try to get it from storage
|
||||||
|
if (!imageDataUrl && annotation.id) {
|
||||||
|
const storedImageData = getImageData(annotation.id);
|
||||||
|
if (storedImageData) {
|
||||||
|
imageDataUrl = storedImageData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) {
|
||||||
|
try {
|
||||||
|
// Convert data URL to bytes
|
||||||
|
const base64Data = imageDataUrl.split(',')[1];
|
||||||
|
const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// Embed image in PDF based on data URL type
|
||||||
|
let image;
|
||||||
|
if (imageDataUrl.includes('data:image/jpeg') || imageDataUrl.includes('data:image/jpg')) {
|
||||||
|
image = await pdfDoc.embedJpg(imageBytes);
|
||||||
|
} else if (imageDataUrl.includes('data:image/png')) {
|
||||||
|
image = await pdfDoc.embedPng(imageBytes);
|
||||||
|
} else {
|
||||||
|
image = await pdfDoc.embedPng(imageBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw image on page at annotation position
|
||||||
|
page.drawImage(image, {
|
||||||
|
x: pdfX,
|
||||||
|
y: pdfY,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (imageError) {
|
||||||
|
console.error('Failed to render image annotation:', imageError);
|
||||||
|
}
|
||||||
|
} else if (annotation.content || annotation.text) {
|
||||||
|
// Handle text annotations
|
||||||
|
page.drawText(annotation.content || annotation.text, {
|
||||||
|
x: pdfX,
|
||||||
|
y: pdfY + height - 12, // Adjust for text baseline
|
||||||
|
size: 12,
|
||||||
|
color: rgb(0, 0, 0)
|
||||||
|
});
|
||||||
|
} else if (annotation.type === 14 || annotation.type === 15) {
|
||||||
|
// Handle ink annotations (drawn signatures)
|
||||||
|
page.drawRectangle({
|
||||||
|
x: pdfX,
|
||||||
|
y: pdfY,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
borderColor: rgb(0, 0, 0),
|
||||||
|
borderWidth: 2,
|
||||||
|
color: rgb(0.9, 0.9, 0.9), // Light gray background
|
||||||
|
opacity: 0.8
|
||||||
|
});
|
||||||
|
|
||||||
|
page.drawText('Drawn Signature', {
|
||||||
|
x: pdfX + 5,
|
||||||
|
y: pdfY + height / 2,
|
||||||
|
size: 10,
|
||||||
|
color: rgb(0, 0, 0)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Handle other annotation types
|
||||||
|
page.drawRectangle({
|
||||||
|
x: pdfX,
|
||||||
|
y: pdfY,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
borderColor: rgb(1, 0, 0),
|
||||||
|
borderWidth: 2,
|
||||||
|
color: rgb(1, 1, 0), // Yellow background
|
||||||
|
opacity: 0.5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (annotationError) {
|
||||||
|
console.warn('Failed to render annotation:', annotationError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Save the PDF with rendered annotations
|
||||||
|
const flattenedPdfBytes = await pdfDoc.save({ useObjectStreams: false, addDefaultPage: false });
|
||||||
|
|
||||||
|
const arrayBuffer = new ArrayBuffer(flattenedPdfBytes.length);
|
||||||
|
const uint8View = new Uint8Array(arrayBuffer);
|
||||||
|
uint8View.set(flattenedPdfBytes);
|
||||||
|
signedFile = new File([arrayBuffer], currentFile.name, { type: 'application/pdf' });
|
||||||
|
|
||||||
|
} catch (renderError) {
|
||||||
|
console.error('Failed to manually render annotations:', renderError);
|
||||||
|
console.warn('Signatures may only show as annotations');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thumbnail and metadata for the signed file
|
||||||
|
const thumbnailResult = await generateThumbnailWithMetadata(signedFile);
|
||||||
|
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
|
||||||
|
|
||||||
|
// Prepare input file data for replacement
|
||||||
|
const inputFileIds: FileId[] = [currentFile.fileId];
|
||||||
|
|
||||||
|
const record = selectors.getStirlingFileStub(currentFile.fileId);
|
||||||
|
if (!record) {
|
||||||
|
console.error('No file record found for:', currentFile.fileId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create output stub and file
|
||||||
|
const outputStub = createNewStirlingFileStub(signedFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||||
|
const outputStirlingFile = createStirlingFile(signedFile, outputStub.id);
|
||||||
|
|
||||||
|
// Replace the original file with the signed version
|
||||||
|
await consumeFiles(inputFileIds, [outputStirlingFile], [outputStub]);
|
||||||
|
|
||||||
|
console.log('✓ Signature flattening completed successfully');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error flattening signatures:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user