From 416d79aed39e94ebc0093bb3934f47672b84c520 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 26 Sep 2025 19:11:03 +0100 Subject: [PATCH] Feature/v2/sign (#4485) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: James Brunton --- frontend/package-lock.json | 50 ++ frontend/package.json | 2 + .../public/locales/en-GB/translation.json | 48 +- frontend/scripts/generate-licenses.js | 12 +- frontend/src/App.tsx | 7 +- frontend/src/assets/3rdPartyLicenses.json | 135 +++++- .../providers/PDFAnnotationProvider.tsx | 91 ++++ .../annotation/shared/BaseAnnotationTool.tsx | 89 ++++ .../annotation/shared/ColorPicker.tsx | 67 +++ .../annotation/shared/DrawingCanvas.tsx | 437 ++++++++++++++++++ .../annotation/shared/DrawingControls.tsx | 60 +++ .../annotation/shared/ImageUploader.tsx | 55 +++ .../annotation/shared/TextInputWithFont.tsx | 126 +++++ .../annotation/tools/DrawingTool.tsx | 45 ++ .../components/annotation/tools/ImageTool.tsx | 67 +++ .../components/annotation/tools/TextTool.tsx | 57 +++ frontend/src/components/shared/RightRail.tsx | 14 +- .../components/tools/sign/PenSizeSelector.tsx | 86 ++++ .../components/tools/sign/SignSettings.tsx | 259 +++++++++++ .../src/components/viewer/EmbedPdfViewer.tsx | 15 + .../components/viewer/HistoryAPIBridge.tsx | 116 +++++ .../src/components/viewer/LocalEmbedPDF.tsx | 105 ++++- .../viewer/LocalEmbedPDFWithAnnotations.tsx | 315 +++++++++++++ .../components/viewer/SignatureAPIBridge.tsx | 370 +++++++++++++++ .../components/viewer/ThumbnailSidebar.tsx | 2 +- frontend/src/contexts/SignatureContext.tsx | 178 +++++++ frontend/src/contexts/ViewerContext.tsx | 4 + .../src/data/useTranslatedToolRegistry.tsx | 7 +- frontend/src/global.d.ts | 1 - .../src/hooks/tools/sign/useSignOperation.ts | 59 +++ .../src/hooks/tools/sign/useSignParameters.ts | 61 +++ frontend/src/tools/Sign.tsx | 176 +++++++ frontend/src/utils/signatureFlattening.ts | 309 +++++++++++++ 33 files changed, 3407 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/annotation/providers/PDFAnnotationProvider.tsx create mode 100644 frontend/src/components/annotation/shared/BaseAnnotationTool.tsx create mode 100644 frontend/src/components/annotation/shared/ColorPicker.tsx create mode 100644 frontend/src/components/annotation/shared/DrawingCanvas.tsx create mode 100644 frontend/src/components/annotation/shared/DrawingControls.tsx create mode 100644 frontend/src/components/annotation/shared/ImageUploader.tsx create mode 100644 frontend/src/components/annotation/shared/TextInputWithFont.tsx create mode 100644 frontend/src/components/annotation/tools/DrawingTool.tsx create mode 100644 frontend/src/components/annotation/tools/ImageTool.tsx create mode 100644 frontend/src/components/annotation/tools/TextTool.tsx create mode 100644 frontend/src/components/tools/sign/PenSizeSelector.tsx create mode 100644 frontend/src/components/tools/sign/SignSettings.tsx create mode 100644 frontend/src/components/viewer/HistoryAPIBridge.tsx create mode 100644 frontend/src/components/viewer/LocalEmbedPDFWithAnnotations.tsx create mode 100644 frontend/src/components/viewer/SignatureAPIBridge.tsx create mode 100644 frontend/src/contexts/SignatureContext.tsx create mode 100644 frontend/src/hooks/tools/sign/useSignOperation.ts create mode 100644 frontend/src/hooks/tools/sign/useSignParameters.ts create mode 100644 frontend/src/tools/Sign.tsx create mode 100644 frontend/src/utils/signatureFlattening.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0fefee3d1..e130f13e0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,9 @@ "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", "@embedpdf/core": "^1.3.0", "@embedpdf/engines": "^1.2.1", + "@embedpdf/plugin-annotation": "^1.3.0", "@embedpdf/plugin-export": "^1.3.0", + "@embedpdf/plugin-history": "^1.3.0", "@embedpdf/plugin-interaction-manager": "^1.3.0", "@embedpdf/plugin-loader": "^1.3.0", "@embedpdf/plugin-pan": "^1.3.0", @@ -532,6 +534,26 @@ "integrity": "sha512-rSBFYjxwQ58L/HcqR0l5Vv4G5t+CCOKlFYrDReTZYNN7fhzKPUWbXUn4ARahZWCNmF8svHumV2P4ArakJJviuw==", "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.0.tgz", @@ -548,6 +570,22 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.0.tgz", @@ -770,6 +808,18 @@ "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": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index f3ebfacf9..ac0e7d114 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", "@embedpdf/core": "^1.3.0", "@embedpdf/engines": "^1.2.1", + "@embedpdf/plugin-annotation": "^1.3.0", "@embedpdf/plugin-export": "^1.3.0", + "@embedpdf/plugin-history": "^1.3.0", "@embedpdf/plugin-interaction-manager": "^1.3.0", "@embedpdf/plugin-loader": "^1.3.0", "@embedpdf/plugin-pan": "^1.3.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 133f61112..334231987 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1732,6 +1732,7 @@ "add": "Add", "saved": "Saved Signatures", "save": "Save Signature", + "applySignatures": "Apply Signatures", "personalSigs": "Personal Signatures", "sharedSigs": "Shared Signatures", "noSavedSigs": "No saved signatures found", @@ -1743,7 +1744,42 @@ "previous": "Previous page", "maintainRatio": "Toggle maintain aspect ratio", "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": { "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." } }, + "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": { "copy": "Copy", "copied": "Copied!", diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index 82f159281..e4b40c0e4 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -1,17 +1,15 @@ #!/usr/bin/env node -import { execSync } from 'node:child_process'; -import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; +const { execSync } = require('node:child_process'); +const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs'); +const path = require('node:path'); -import { argv } from 'node:process'; +const { argv } = require('node:process'); const inputIdx = argv.indexOf('--input'); const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null; const POSTPROCESS_ONLY = !!INPUT_FILE; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +// __dirname is available in CommonJS by default /** * Generate 3rd party licenses for frontend dependencies diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 767fa918a..da9a14eb5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import "./styles/cookieconsent.css"; import "./index.css"; import { RightRailProvider } from "./contexts/RightRailContext"; import { ViewerProvider } from "./contexts/ViewerContext"; +import { SignatureProvider } from "./contexts/SignatureContext"; // Import file ID debugging helpers (development only) import "./utils/fileIdSafety"; @@ -45,9 +46,11 @@ export default function App() { + - - + + + diff --git a/frontend/src/assets/3rdPartyLicenses.json b/frontend/src/assets/3rdPartyLicenses.json index df08332ea..392249d42 100644 --- a/frontend/src/assets/3rdPartyLicenses.json +++ b/frontend/src/assets/3rdPartyLicenses.json @@ -7,6 +7,132 @@ "moduleLicense": "Apache-2.0", "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", "moduleUrl": "git+https://github.com/emotion-js/emotion.git#main", @@ -35,6 +161,13 @@ "moduleLicense": "MIT", "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", "moduleUrl": "git+https://github.com/mantinedev/mantine.git", @@ -143,7 +276,7 @@ { "moduleName": "posthog-js", "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", "moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git" }, diff --git a/frontend/src/components/annotation/providers/PDFAnnotationProvider.tsx b/frontend/src/components/annotation/providers/PDFAnnotationProvider.tsx new file mode 100644 index 000000000..0979d59e3 --- /dev/null +++ b/frontend/src/components/annotation/providers/PDFAnnotationProvider.tsx @@ -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(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 = ({ + 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 ( + + {children} + + ); +}; + +export const usePDFAnnotation = (): PDFAnnotationContextValue => { + const context = useContext(PDFAnnotationContext); + if (context === undefined) { + throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider'); + } + return context; +}; \ No newline at end of file diff --git a/frontend/src/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/components/annotation/shared/BaseAnnotationTool.tsx new file mode 100644 index 000000000..cf30f2e7e --- /dev/null +++ b/frontend/src/components/annotation/shared/BaseAnnotationTool.tsx @@ -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 = ({ + 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(null); + + const handleSignatureDataChange = (data: string | null) => { + setSignatureData(data); + onSignatureDataChange?.(data); + }; + + const handlePlaceSignature = () => { + if (activateSignaturePlacementMode) { + activateSignaturePlacementMode(); + } + }; + + return ( + + {/* Drawing Controls (Undo/Redo/Place) */} + + + {/* Tool Content */} + {React.cloneElement(children as React.ReactElement, { + selectedColor, + signatureData, + onSignatureDataChange: handleSignatureDataChange, + onColorSwatchClick: () => setIsColorPickerOpen(true), + disabled + })} + + {/* Instructions for placing signature */} + + + Click anywhere on the PDF to place your annotation. + + + + {/* Color Picker Modal */} + setIsColorPickerOpen(false)} + selectedColor={selectedColor} + onColorChange={setSelectedColor} + /> + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/annotation/shared/ColorPicker.tsx b/frontend/src/components/annotation/shared/ColorPicker.tsx new file mode 100644 index 000000000..40bb363b4 --- /dev/null +++ b/frontend/src/components/annotation/shared/ColorPicker.tsx @@ -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 = ({ + isOpen, + onClose, + selectedColor, + onColorChange, + title = "Choose Color" +}) => { + return ( + + + + + + + + + ); +}; + +interface ColorSwatchButtonProps { + color: string; + onClick: () => void; + size?: number; +} + +export const ColorSwatchButton: React.FC = ({ + color, + onClick, + size = 24 +}) => { + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/components/annotation/shared/DrawingCanvas.tsx new file mode 100644 index 000000000..d4ae74ad0 --- /dev/null +++ b/frontend/src/components/annotation/shared/DrawingCanvas.tsx @@ -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 = ({ + selectedColor, + penSize, + penSizeInput, + onColorSwatchClick, + onPenSizeChange, + onPenSizeInputChange, + onSignatureDataChange, + disabled = false, + width = 400, + height = 150, + modalWidth = 800, + modalHeight = 400, + additionalButtons +}) => { + const canvasRef = useRef(null); + const modalCanvasRef = useRef(null); + const visibleModalCanvasRef = useRef(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) => { + 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) => { + 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) => { + 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) => { + 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 ( + <> + + + + Draw your signature + +
+ Color + + + +
+
+ Pen Size + +
+
+ +
+
+
+ + +
+ {additionalButtons} +
+ +
+
+
+ + {/* Hidden canvas for modal synchronization */} + + + {/* Modal for larger signature canvas */} + setIsModalOpen(false)} + title="Draw Your Signature" + size="xl" + centered + > + + {/* Color and Pen Size picker */} + + +
+ Color + +
+
+ Pen Size + +
+
+
+ + + + + + + + + + + + +
+
+ + ); +}; + +export default DrawingCanvas; \ No newline at end of file diff --git a/frontend/src/components/annotation/shared/DrawingControls.tsx b/frontend/src/components/annotation/shared/DrawingControls.tsx new file mode 100644 index 000000000..62c7c615f --- /dev/null +++ b/frontend/src/components/annotation/shared/DrawingControls.tsx @@ -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 = ({ + onUndo, + onRedo, + onPlaceSignature, + hasSignatureData = false, + disabled = false, + showPlaceButton = true, + placeButtonText = "Update and Place" +}) => { + const { t } = useTranslation(); + + return ( + + {/* Undo/Redo Controls */} + + + + {/* Place Signature Button */} + {showPlaceButton && onPlaceSignature && ( + + )} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/annotation/shared/ImageUploader.tsx b/frontend/src/components/annotation/shared/ImageUploader.tsx new file mode 100644 index 000000000..d590a7bc1 --- /dev/null +++ b/frontend/src/components/annotation/shared/ImageUploader.tsx @@ -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 = ({ + 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 ( + + + + {hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/components/annotation/shared/TextInputWithFont.tsx new file mode 100644 index 000000000..b85511cd5 --- /dev/null +++ b/frontend/src/components/annotation/shared/TextInputWithFont.tsx @@ -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 = ({ + 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 ( + + onTextChange(e.target.value)} + disabled={disabled} + required + /> + + {/* Font Selection */} +