diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java
index b8fa08739..3bcc48715 100644
--- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java
+++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java
@@ -258,12 +258,6 @@ public class AppConfig {
return false;
}
- @Bean(name = "GoogleDriveEnabled")
- @Profile("default")
- public boolean googleDriveEnabled() {
- return false;
- }
-
@Bean(name = "license")
@Profile("default")
public String licenseType() {
diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
index 7eeace787..14704d825 100644
--- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
+++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java
@@ -530,7 +530,6 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
- private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class CustomMetadata {
@@ -549,26 +548,6 @@ public class ApplicationProperties {
: producer;
}
}
-
- @Data
- public static class GoogleDrive {
- private boolean enabled;
- private String clientId;
- private String apiKey;
- private String appId;
-
- public String getClientId() {
- return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
- }
-
- public String getApiKey() {
- return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
- }
-
- public String getAppId() {
- return appId == null || appId.trim().isEmpty() ? "" : appId;
- }
- }
}
@Data
diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java
index da83fd462..66078099f 100644
--- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java
+++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java
@@ -109,22 +109,6 @@ class ApplicationPropertiesLogicTest {
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
- @Test
- void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
- Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
-
- assertEquals("", gd.getClientId());
- assertEquals("", gd.getApiKey());
- assertEquals("", gd.getAppId());
-
- gd.setClientId(" id ");
- gd.setApiKey(" key ");
- gd.setAppId(" app ");
- assertEquals(" id ", gd.getClientId());
- assertEquals(" key ", gd.getApiKey());
- assertEquals(" app ", gd.getAppId());
- }
-
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
index 6d9263270..072471e5c 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java
@@ -98,11 +98,6 @@ public class ConfigController {
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
- if (applicationContext.containsBean("GoogleDriveEnabled")) {
- configData.put(
- "GoogleDriveEnabled",
- applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
- }
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template
index 465f95fb6..8143ba4c2 100644
--- a/app/core/src/main/resources/settings.yml.template
+++ b/app/core/src/main/resources/settings.yml.template
@@ -76,11 +76,6 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
- googleDrive:
- enabled: false
- clientId: ''
- apiKey: ''
- appId: ''
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
diff --git a/app/core/src/main/resources/templates/fragments/common.html b/app/core/src/main/resources/templates/fragments/common.html
index d3b888a1d..973822b58 100644
--- a/app/core/src/main/resources/templates/fragments/common.html
+++ b/app/core/src/main/resources/templates/fragments/common.html
@@ -422,10 +422,6 @@
-
-
-
@@ -443,16 +439,4 @@
-
-
-
-
-
-
-
-
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java
index 215b82347..2e71b670d 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java
@@ -12,7 +12,6 @@ import org.springframework.core.annotation.Order;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.common.model.ApplicationProperties.Premium;
-import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@@ -55,19 +54,6 @@ public class EEAppConfig {
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
- @Profile("security")
- @Bean(name = "GoogleDriveEnabled")
- @Primary
- public boolean googleDriveEnabled() {
- return runningProOrHigher()
- && applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
- }
-
- @Bean(name = "GoogleDriveConfig")
- public GoogleDrive googleDriveConfig() {
- return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
- }
-
// TODO: Remove post migration
@SuppressWarnings("deprecation")
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
diff --git a/docker/README.md b/docker/README.md
index df07e6b9e..99b86b53e 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -50,7 +50,14 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
- **Custom Ports**: Modify port mappings in docker-compose files
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
+### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
+
+- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
+- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
+- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
+
## Development vs Production
- **Development**: Keep backend port 8080 exposed for debugging
-- **Production**: Remove backend port exposure, use only frontend proxy
\ No newline at end of file
+- **Production**: Remove backend port exposure, use only frontend proxy
+
diff --git a/docker/compose/docker-compose.fat.yml b/docker/compose/docker-compose.fat.yml
index 1757782d5..d0242eb3c 100644
--- a/docker/compose/docker-compose.fat.yml
+++ b/docker/compose/docker-compose.fat.yml
@@ -47,6 +47,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
+ #VITE_GOOGLE_DRIVE_CLIENT_ID:
+ #VITE_GOOGLE_DRIVE_API_KEY:
+ #VITE_GOOGLE_DRIVE_APP_ID:
depends_on:
- backend
networks:
diff --git a/docker/compose/docker-compose.ultra-lite.yml b/docker/compose/docker-compose.ultra-lite.yml
index bfbf55861..0639b53ac 100644
--- a/docker/compose/docker-compose.ultra-lite.yml
+++ b/docker/compose/docker-compose.ultra-lite.yml
@@ -44,6 +44,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
+ #VITE_GOOGLE_DRIVE_CLIENT_ID:
+ #VITE_GOOGLE_DRIVE_API_KEY:
+ #VITE_GOOGLE_DRIVE_APP_ID:
depends_on:
- backend
networks:
diff --git a/docker/compose/docker-compose.yml b/docker/compose/docker-compose.yml
index b0061f785..6f8b1ace8 100644
--- a/docker/compose/docker-compose.yml
+++ b/docker/compose/docker-compose.yml
@@ -46,6 +46,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
+ #VITE_GOOGLE_DRIVE_CLIENT_ID:
+ #VITE_GOOGLE_DRIVE_API_KEY:
+ #VITE_GOOGLE_DRIVE_APP_ID:
depends_on:
- backend
networks:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index f73143e83..51e500cb7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -54,6 +54,7 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
+ "signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -66,6 +67,10 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/gapi": "^0.0.47",
+ "@types/gapi.client.drive-v3": "^0.0.5",
+ "@types/google.accounts": "^0.0.18",
+ "@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
@@ -2009,6 +2014,28 @@
"react": "^18.x || ^19.x"
}
},
+ "node_modules/@maxim_mazurok/gapi.client.discovery-v1": {
+ "version": "0.4.20200806",
+ "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz",
+ "integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/gapi.client": "*",
+ "@types/gapi.client.discovery-v1": "*"
+ }
+ },
+ "node_modules/@maxim_mazurok/gapi.client.drive-v3": {
+ "version": "0.1.20250930",
+ "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20250930.tgz",
+ "integrity": "sha512-zNR7HtaFl2Pvf8Ck2zP8cppUst7ouY2isKn7hrGf6hQ4/0ULsu19qMRSQgRb0HxBYcGjak7kGK4pZI4a2z4CWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/gapi.client": "*",
+ "@types/gapi.client.discovery-v1": "*"
+ }
+ },
"node_modules/@mui/core-downloads-tracker": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz",
@@ -3595,6 +3622,54 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/gapi": {
+ "version": "0.0.47",
+ "resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz",
+ "integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/gapi.client": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz",
+ "integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/gapi.client.discovery-v1": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz",
+ "integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@maxim_mazurok/gapi.client.discovery-v1": "latest"
+ }
+ },
+ "node_modules/@types/gapi.client.drive-v3": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz",
+ "integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@maxim_mazurok/gapi.client.drive-v3": "latest"
+ }
+ },
+ "node_modules/@types/google.accounts": {
+ "version": "0.0.18",
+ "resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz",
+ "integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/google.picker": {
+ "version": "0.0.51",
+ "resolved": "https://registry.npmjs.org/@types/google.picker/-/google.picker-0.0.51.tgz",
+ "integrity": "sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
@@ -9918,6 +9993,12 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/signature_pad": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.1.tgz",
+ "integrity": "sha512-BT5JJygS5BS0oV+tffPRorIud6q17bM7v/1LdQwd0o6mTqGoI25yY1NjSL99OqkekWltS4uon6p52Y8j1Zqu7g==",
+ "license": "MIT"
+ },
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 319653af1..4eb01f202 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -50,6 +50,7 @@
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
+ "signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
@@ -105,6 +106,10 @@
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/gapi": "^0.0.47",
+ "@types/gapi.client.drive-v3": "^0.0.5",
+ "@types/google.accounts": "^0.0.18",
+ "@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 71893df3c..d11bda931 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -1836,8 +1836,16 @@
"placeholder": "Enter your full name"
},
"instructions": {
- "title": "How to add signature"
+ "title": "How to add signature",
+ "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
+ "image": "After uploading your signature image above, click anywhere on the PDF to place it.",
+ "text": "After entering your name above, click anywhere on the PDF to place your signature."
},
+ "mode": {
+ "move": "Move Signature",
+ "place": "Place Signature"
+ },
+ "updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
"deactivate": "Stop Placing Signatures",
"results": {
diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx
index fce70a022..5ee593916 100644
--- a/frontend/src/components/FileManager.tsx
+++ b/frontend/src/components/FileManager.tsx
@@ -10,6 +10,8 @@ import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { Z_INDEX_FILE_MANAGER_MODAL } from '../styles/zIndex';
+import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
+import { loadScript } from '../utils/scriptLoader';
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -85,6 +87,29 @@ const FileManager: React.FC = ({ selectedTool }) => {
};
}, []);
+ // Preload Google Drive scripts if configured
+ useEffect(() => {
+ if (isGoogleDriveConfigured()) {
+ // Load scripts in parallel without blocking
+ Promise.all([
+ loadScript({
+ src: 'https://apis.google.com/js/api.js',
+ id: 'gapi-script',
+ async: true,
+ defer: true,
+ }),
+ loadScript({
+ src: 'https://accounts.google.com/gsi/client',
+ id: 'gis-script',
+ async: true,
+ defer: true,
+ }),
+ ]).catch((error) => {
+ console.warn('Failed to preload Google Drive scripts:', error);
+ });
+ }
+ }, []);
+
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
diff --git a/frontend/src/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/components/annotation/shared/DrawingCanvas.tsx
index 39d1581e9..87362f74d 100644
--- a/frontend/src/components/annotation/shared/DrawingCanvas.tsx
+++ b/frontend/src/components/annotation/shared/DrawingCanvas.tsx
@@ -1,7 +1,8 @@
-import React, { useRef, useState, useCallback, useEffect } from 'react';
-import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
+import React, { useRef, useState } from 'react';
+import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import { ColorSwatchButton } from './ColorPicker';
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
+import SignaturePad from 'signature_pad';
interface DrawingCanvasProps {
selectedColor: string;
@@ -11,6 +12,7 @@ interface DrawingCanvasProps {
onPenSizeChange: (size: number) => void;
onPenSizeInputChange: (input: string) => void;
onSignatureDataChange: (data: string | null) => void;
+ onDrawingComplete?: () => void;
disabled?: boolean;
width?: number;
height?: number;
@@ -27,411 +29,253 @@ export const DrawingCanvas: React.FC = ({
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
+ onDrawingComplete,
disabled = false,
width = 400,
height = 150,
- modalWidth = 800,
- modalHeight = 400,
- additionalButtons
}) => {
- const canvasRef = useRef(null);
+ const previewCanvasRef = useRef(null);
const modalCanvasRef = useRef(null);
- const visibleModalCanvasRef = useRef(null);
+ const padRef = useRef(null);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [colorPickerOpen, setColorPickerOpen] = useState(false);
- const [isDrawing, setIsDrawing] = useState(false);
- const [isModalDrawing, setIsModalDrawing] = useState(false);
- const [isModalOpen, setIsModalOpen] = useState(false);
+ const initPad = (canvas: HTMLCanvasElement) => {
+ if (!padRef.current) {
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = rect.width;
+ canvas.height = rect.height;
- // 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);
+ padRef.current = new SignaturePad(canvas, {
+ penColor: selectedColor,
+ minWidth: penSize * 0.5,
+ maxWidth: penSize * 2.5,
+ throttle: 10,
+ minDistance: 5,
+ velocityFilterWeight: 0.7,
+ });
}
- }, [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();
+ const openModal = () => {
+ // Clear pad ref so it reinitializes
+ if (padRef.current) {
+ padRef.current.off();
+ padRef.current = null;
}
- }, [isDrawing, disabled]);
+ setModalOpen(true);
+ };
- const stopDrawing = useCallback(() => {
- if (!isDrawing || disabled) return;
+ const trimCanvas = (canvas: HTMLCanvasElement): string => {
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return canvas.toDataURL('image/png');
- setIsDrawing(false);
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const pixels = imageData.data;
- // Save canvas as signature data
- if (canvasRef.current) {
- const dataURL = canvasRef.current.toDataURL('image/png');
- onSignatureDataChange(dataURL);
- }
- }, [isDrawing, disabled, onSignatureDataChange]);
+ let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
- // 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;
+ // Find bounds of non-transparent pixels
+ for (let y = 0; y < canvas.height; y++) {
+ for (let x = 0; x < canvas.width; x++) {
+ const alpha = pixels[(y * canvas.width + x) * 4 + 3];
+ if (alpha > 0) {
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
}
}
}
- }, [isModalDrawing]);
- // Clear canvas functions
- const clearCanvas = useCallback(() => {
- if (!canvasRef.current || disabled) return;
+ const trimWidth = maxX - minX + 1;
+ const trimHeight = maxY - minY + 1;
- 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);
- }
+ // Create trimmed canvas
+ const trimmedCanvas = document.createElement('canvas');
+ trimmedCanvas.width = trimWidth;
+ trimmedCanvas.height = trimHeight;
+ const trimmedCtx = trimmedCanvas.getContext('2d');
+ if (trimmedCtx) {
+ trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
}
- if (visibleModalCanvasRef.current) {
- const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
- if (visibleCtx) {
- visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
- }
- }
+ return trimmedCanvas.toDataURL('image/png');
+ };
- // 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);
- }
- }
+ const closeModal = () => {
+ if (padRef.current && !padRef.current.isEmpty()) {
+ const canvas = modalCanvasRef.current;
+ if (canvas) {
+ const trimmedPng = trimCanvas(canvas);
+ onSignatureDataChange(trimmedPng);
- 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) {
+ // Update preview canvas with proper aspect ratio
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);
+ if (previewCanvasRef.current) {
+ const ctx = previewCanvasRef.current.getContext('2d');
+ if (ctx) {
+ ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
+
+ // Calculate scaling to fit within preview canvas while maintaining aspect ratio
+ const scale = Math.min(
+ previewCanvasRef.current.width / img.width,
+ previewCanvasRef.current.height / img.height
+ );
+ const scaledWidth = img.width * scale;
+ const scaledHeight = img.height * scale;
+ const x = (previewCanvasRef.current.width - scaledWidth) / 2;
+ const y = (previewCanvasRef.current.height - scaledHeight) / 2;
+
+ ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
+ }
+ }
};
- img.src = dataURL;
- }
- }
+ img.src = trimmedPng;
- 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);
+ if (onDrawingComplete) {
+ onDrawingComplete();
}
}
- }, 300);
- }, [selectedColor, penSize]);
+ }
+ if (padRef.current) {
+ padRef.current.off();
+ padRef.current = null;
+ }
+ setModalOpen(false);
+ };
- // Initialize canvas settings whenever color or pen size changes
- useEffect(() => {
- const updateCanvas = (canvas: HTMLCanvasElement | null) => {
- if (!canvas) return;
- const ctx = canvas.getContext('2d');
+ const clear = () => {
+ if (padRef.current) {
+ padRef.current.clear();
+ }
+ if (previewCanvasRef.current) {
+ const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
- ctx.strokeStyle = selectedColor;
- ctx.lineWidth = penSize;
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
+ ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
- };
+ }
+ onSignatureDataChange(null);
+ };
- updateCanvas(canvasRef.current);
- updateCanvas(modalCanvasRef.current);
- updateCanvas(visibleModalCanvasRef.current);
- }, [selectedColor, penSize]);
+ const updatePenColor = (color: string) => {
+ if (padRef.current) {
+ padRef.current.penColor = color;
+ }
+ };
+
+ const updatePenSize = (size: number) => {
+ if (padRef.current) {
+ padRef.current.minWidth = size * 0.8;
+ padRef.current.maxWidth = size * 1.2;
+ }
+ };
return (
<>
-
- Draw your signature
-
-
- Color
-
-
-
-
-
-
-
-
-
-
+ Draw your signature
-
-
- {additionalButtons}
-
-
-
+
+ Click to open drawing canvas
+
- {/* 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
-
-
-
-
-
+
+
+
Color
+
+
+
+ setColorPickerOpen(!colorPickerOpen)}
+ />
+
+
+
+ {
+ onColorSwatchClick();
+ updatePenColor(color);
+ }}
+ swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
+ />
+
+
+
+
+
Pen Size
+
{
+ onPenSizeChange(size);
+ updatePenSize(size);
+ }}
+ onInputChange={onPenSizeInputChange}
+ placeholder="Size"
+ size="compact-sm"
+ style={{ width: '60px' }}
+ />
+
+
-
-
-
+
>
);
};
-export default DrawingCanvas;
\ No newline at end of file
+export default DrawingCanvas;
diff --git a/frontend/src/components/annotation/shared/ImageUploader.tsx b/frontend/src/components/annotation/shared/ImageUploader.tsx
index d590a7bc1..aabca815c 100644
--- a/frontend/src/components/annotation/shared/ImageUploader.tsx
+++ b/frontend/src/components/annotation/shared/ImageUploader.tsx
@@ -48,7 +48,7 @@ export const ImageUploader: React.FC = ({
disabled={disabled}
/>
- {hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')}
+ {hint || t('sign.image.hint', 'Upload an image of your signature')}
);
diff --git a/frontend/src/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/components/annotation/shared/TextInputWithFont.tsx
index b85511cd5..b7af60295 100644
--- a/frontend/src/components/annotation/shared/TextInputWithFont.tsx
+++ b/frontend/src/components/annotation/shared/TextInputWithFont.tsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
-import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core';
+import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
+import { ColorPicker } from './ColorPicker';
interface TextInputWithFontProps {
text: string;
@@ -9,6 +10,8 @@ interface TextInputWithFontProps {
onFontSizeChange: (size: number) => void;
fontFamily: string;
onFontFamilyChange: (family: string) => void;
+ textColor?: string;
+ onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
@@ -21,6 +24,8 @@ export const TextInputWithFont: React.FC = ({
onFontSizeChange,
fontFamily,
onFontFamilyChange,
+ textColor = '#000000',
+ onTextColorChange,
disabled = false,
label,
placeholder
@@ -28,6 +33,7 @@ export const TextInputWithFont: React.FC = ({
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
+ const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// Sync font size input with prop changes
useEffect(() => {
@@ -42,7 +48,7 @@ export const TextInputWithFont: React.FC = ({
{ value: 'Georgia', label: 'Georgia' },
];
- const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48'];
+ const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
return (
@@ -66,61 +72,101 @@ export const TextInputWithFont: React.FC = ({
allowDeselect={false}
/>
- {/* Font Size */}
- {
- setFontSizeInput(optionValue);
- const size = parseInt(optionValue);
- if (!isNaN(size)) {
- onFontSizeChange(size);
- }
- fontSizeCombobox.closeDropdown();
- }}
- store={fontSizeCombobox}
- withinPortal={false}
- >
-
- {
- const value = event.currentTarget.value;
- setFontSizeInput(value);
+ {/* Font Size and Color */}
+
+ {
+ setFontSizeInput(optionValue);
+ const size = parseInt(optionValue);
+ if (!isNaN(size)) {
+ onFontSizeChange(size);
+ }
+ fontSizeCombobox.closeDropdown();
+ }}
+ store={fontSizeCombobox}
+ withinPortal={false}
+ >
+
+ {
+ 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);
+ // Parse and validate the typed value in real-time
+ const size = parseInt(value);
+ if (!isNaN(size) && size >= 8 && size <= 200) {
+ 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 > 200) {
+ setFontSizeInput(fontSize.toString());
+ } else {
+ onFontSizeChange(size);
+ }
+ }}
+ disabled={disabled}
+ />
+
+
+
+
+ {fontSizeOptions.map((size) => (
+
+ {size}px
+
+ ))}
+
+
+
+
+ {/* Text Color Picker */}
+ {onTextColorChange && (
+
+ !disabled && setIsColorPickerOpen(true)}
+ style={{ cursor: disabled ? 'default' : 'pointer' }}
+ rightSection={
+
}
+ />
+
+ )}
+
- 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}
- />
-
-
-
-
- {fontSizeOptions.map((size) => (
-
- {size}px
-
- ))}
-
-
-
+ {/* Color Picker Modal */}
+ {onTextColorChange && (
+ setIsColorPickerOpen(false)}
+ selectedColor={textColor}
+ onColorChange={onTextColorChange}
+ />
+ )}
);
};
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx
index d2d28e09e..78ab8ce39 100644
--- a/frontend/src/components/fileManager/FileSourceButtons.tsx
+++ b/frontend/src/components/fileManager/FileSourceButtons.tsx
@@ -5,6 +5,7 @@ import UploadIcon from '@mui/icons-material/Upload';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
+import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -13,8 +14,20 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC = ({
horizontal = false
}) => {
- const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
+ const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { t } = useTranslation();
+ const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
+
+ const handleGoogleDriveClick = async () => {
+ try {
+ const files = await openGoogleDrivePicker({ multiple: true });
+ if (files.length > 0) {
+ onGoogleDriveSelect(files);
+ }
+ } catch (error) {
+ console.error('Failed to pick files from Google Drive:', error);
+ }
+ };
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
@@ -67,15 +80,24 @@ const FileSourceButtons: React.FC = ({
}
justify={horizontal ? "center" : "flex-start"}
- onClick={() => onSourceChange('drive')}
+ onClick={handleGoogleDriveClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
- disabled
- color={activeSource === 'drive' ? 'gray' : undefined}
- styles={buttonProps.getStyles('drive')}
+ disabled={!isGoogleDriveEnabled}
+ styles={{
+ root: {
+ backgroundColor: 'transparent',
+ border: 'none',
+ '&:hover': {
+ backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
+ }
+ }
+ }}
+ title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
diff --git a/frontend/src/components/shared/config/configSections/Overview.tsx b/frontend/src/components/shared/config/configSections/Overview.tsx
index e591655e0..d3f250f49 100644
--- a/frontend/src/components/shared/config/configSections/Overview.tsx
+++ b/frontend/src/components/shared/config/configSections/Overview.tsx
@@ -51,7 +51,6 @@ const Overview: React.FC = () => {
} : null;
const integrationConfig = config ? {
- GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
diff --git a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx
index d686153c4..0ffa3102e 100644
--- a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx
+++ b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx
@@ -10,6 +10,7 @@ import { useFileState, useFileContext } from '../../../contexts/FileContext';
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { createProcessedFile } from '../../../contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
+import { useNavigationState } from '../../../contexts/NavigationContext';
interface ViewerAnnotationControlsProps {
currentView: string;
@@ -26,13 +27,17 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
- const { signatureApiRef } = useSignature();
+ const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
+ // Check if we're in sign mode
+ const { selectedTool } = useNavigationState();
+ const isSignMode = selectedTool === 'sign';
+
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
@@ -40,6 +45,11 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
}
}, [currentView, viewerContext]);
+ // Don't show any annotation controls in sign mode
+ if (isSignMode) {
+ return null;
+ }
+
return (
<>
{/* Annotation Visibility Toggle */}
@@ -51,7 +61,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
- disabled={disabled || viewerContext?.isAnnotationMode}
+ disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
{
const { t } = useTranslation();
+ const { isPlacementMode } = useSignature();
// State for drawing
const [selectedColor, setSelectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
+ const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move');
// State for different signature types
const [canvasSignatureData, setCanvasSignatureData] = useState(null);
@@ -96,20 +99,29 @@ const SignSettings = ({
}
}, [parameters.signatureType]);
- // Handle text signature activation
+ // Handle text signature activation (including fontSize and fontFamily changes)
useEffect(() => {
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
if (onActivateSignaturePlacement) {
+ setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
}
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
if (onDeactivateSignature) {
+ setInteractionMode('move');
onDeactivateSignature();
}
}
- }, [parameters.signatureType, parameters.signerName, onActivateSignaturePlacement, onDeactivateSignature]);
+ }, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]);
+
+ // Reset to move mode when placement mode is deactivated
+ useEffect(() => {
+ if (!isPlacementMode && interactionMode === 'place') {
+ setInteractionMode('move');
+ }
+ }, [isPlacementMode, interactionMode]);
// Handle signature data updates
useEffect(() => {
@@ -130,12 +142,23 @@ const SignSettings = ({
// Handle image signature activation - activate when image data syncs with parameters
useEffect(() => {
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
+ setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
}
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
+ // Handle canvas signature activation - activate when canvas data syncs with parameters
+ useEffect(() => {
+ if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) {
+ setInteractionMode('place');
+ setTimeout(() => {
+ onActivateSignaturePlacement();
+ }, 100);
+ }
+ }, [parameters.signatureType, parameters.signatureData, canvasSignatureData]);
+
// Draw settings are no longer needed since draw mode is removed
return (
@@ -170,7 +193,7 @@ const SignSettings = ({
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
disabled={disabled}
showPlaceButton={false}
- placeButtonText="Update and Place"
+ placeButtonText={t('sign.updateAndPlace', 'Update and Place')}
/>
{/* Signature Creation based on type */}
@@ -183,6 +206,11 @@ const SignSettings = ({
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
+ onDrawingComplete={() => {
+ if (onActivateSignaturePlacement) {
+ onActivateSignaturePlacement();
+ }
+ }}
disabled={disabled}
additionalButtons={
}
/>
@@ -216,17 +244,43 @@ const SignSettings = ({
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
+ textColor={parameters.textColor || '#000000'}
+ onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
)}
+ {/* Interaction Mode Toggle */}
+ {(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && (
+ {
+ setInteractionMode(value as 'move' | 'place');
+ if (value === 'place') {
+ if (onActivateSignaturePlacement) {
+ onActivateSignaturePlacement();
+ }
+ } else {
+ if (onDeactivateSignature) {
+ onDeactivateSignature();
+ }
+ }
+ }}
+ data={[
+ { label: t('sign.mode.move', 'Move Signature'), value: 'move' },
+ { label: t('sign.mode.place', 'Place Signature'), value: 'place' }
+ ]}
+ fullWidth
+ />
+ )}
+
{/* Instructions for placing signature */}
- {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.'}
+ {parameters.signatureType === 'canvas' && t('sign.instructions.canvas', 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.')}
+ {parameters.signatureType === 'image' && t('sign.instructions.image', 'After uploading your signature image above, click anywhere on the PDF to place it.')}
+ {parameters.signatureType === 'text' && t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.')}
diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx
index 117402cbf..5ed8a1d46 100644
--- a/frontend/src/components/viewer/EmbedPdfViewer.tsx
+++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx
@@ -13,6 +13,7 @@ import { useNavigationGuard, useNavigationState } from '../../contexts/Navigatio
import { useSignature } from '../../contexts/SignatureContext';
import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers';
import NavigationWarningModal from '../shared/NavigationWarningModal';
+import { isStirlingFile } from '../../types/fileContext';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@@ -263,6 +264,7 @@ const EmbedPdfViewerContent = ({
transition: 'margin-right 0.3s ease'
}}>
{/* Selection layer for text interaction */}
-
+
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
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;
}
export const SignatureAPIBridge = forwardRef(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
- const { isAnnotationMode } = useViewer();
- // Enable keyboard deletion of selected annotations - when in signature placement mode or viewer annotation mode
+ // Enable keyboard deletion of selected annotations
useEffect(() => {
- if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return;
+ // Always enable delete key when we have annotation API and are in sign mode
+ if (!annotationApi || (isPlacementMode === undefined)) return;
- const handleKeyDown = (event: KeyboardEvent) => {
+ const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
const selectedAnnotation = annotationApi.getSelectedAnnotation?.();
@@ -67,7 +63,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
- }, [annotationApi, storeImageData, isPlacementMode, isAnnotationMode]);
+ }, [annotationApi, storeImageData, isPlacementMode]);
useImperativeHandle(ref, () => ({
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
@@ -100,34 +96,6 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI
});
},
- 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;
@@ -152,45 +120,31 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI
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;
- }
- }
+ // Skip native text tools - always use stamp for consistent sizing
+ const activatedTool = null;
if (!activatedTool) {
- // Fallback: create a simple text image as stamp
+ // Create text image as stamp with actual pixel size matching desired display size
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
- const fontSize = signatureConfig.fontSize || 16;
+ const baseFontSize = signatureConfig.fontSize || 16;
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
+ const textColor = signatureConfig.textColor || '#000000';
- canvas.width = Math.max(200, signatureConfig.signerName.length * fontSize * 0.6);
- canvas.height = fontSize + 20;
- ctx.fillStyle = '#000000';
- ctx.font = `${fontSize}px ${fontFamily}`;
+ // Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
+ canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
+ canvas.height = baseFontSize + 20;
+
+ ctx.fillStyle = textColor;
+ ctx.font = `${baseFontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
const dataURL = canvas.toDataURL();
+ // Deactivate and reactivate to force refresh
+ annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
@@ -205,6 +159,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI
// 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,
@@ -267,84 +222,6 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI
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 => {
if (!annotationApi || !annotationApi.getPageAnnotations) {
console.warn('getPageAnnotations not available');
diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx
index 28a30fc20..8c7f6182d 100644
--- a/frontend/src/contexts/FileManagerContext.tsx
+++ b/frontend/src/contexts/FileManagerContext.tsx
@@ -39,6 +39,7 @@ interface FileManagerContextValue {
onAddToRecents: (file: StirlingFileStub) => void;
onUnzipFile: (file: StirlingFileStub) => Promise;
onNewFilesSelect: (files: File[]) => void;
+ onGoogleDriveSelect: (files: File[]) => void;
// External props
recentFiles: StirlingFileStub[];
@@ -546,6 +547,19 @@ export const FileManagerProvider: React.FC = ({
}
}, [refreshRecentFiles]);
+ const handleGoogleDriveSelect = useCallback(async (files: File[]) => {
+ if (files.length > 0) {
+ try {
+ // Process Google Drive files same as local files
+ onNewFilesSelect(files);
+ await refreshRecentFiles();
+ onClose();
+ } catch (error) {
+ console.error('Failed to process Google Drive files:', error);
+ }
+ }
+ }, [onNewFilesSelect, refreshRecentFiles, onClose]);
+
const handleUnzipFile = useCallback(async (file: StirlingFileStub) => {
try {
// Load the full file from storage
@@ -623,6 +637,7 @@ export const FileManagerProvider: React.FC = ({
onAddToRecents: handleAddToRecents,
onUnzipFile: handleUnzipFile,
onNewFilesSelect,
+ onGoogleDriveSelect: handleGoogleDriveSelect,
// External props
recentFiles,
@@ -656,6 +671,7 @@ export const FileManagerProvider: React.FC = ({
handleAddToRecents,
handleUnzipFile,
onNewFilesSelect,
+ handleGoogleDriveSelect,
recentFiles,
isFileSupported,
modalHeight,
diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts
index a646b90fe..3811572c0 100644
--- a/frontend/src/contexts/file/FileReducer.ts
+++ b/frontend/src/contexts/file/FileReducer.ts
@@ -51,8 +51,9 @@ function processFileSwap(
}
});
- // Clear selections that reference removed files
+ // Clear selections that reference removed files and add new files to selection
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id));
+ const newSelectedFileIds = [...validSelectedFileIds, ...addedIds];
return {
...state,
@@ -62,7 +63,7 @@ function processFileSwap(
},
ui: {
...state.ui,
- selectedFileIds: validSelectedFileIds
+ selectedFileIds: newSelectedFileIds
}
};
}
diff --git a/frontend/src/hooks/tools/sign/useSignParameters.ts b/frontend/src/hooks/tools/sign/useSignParameters.ts
index c96a31103..bc379a36d 100644
--- a/frontend/src/hooks/tools/sign/useSignParameters.ts
+++ b/frontend/src/hooks/tools/sign/useSignParameters.ts
@@ -17,6 +17,7 @@ export interface SignParameters {
signerName?: string;
fontFamily?: string;
fontSize?: number;
+ textColor?: string;
}
export const DEFAULT_PARAMETERS: SignParameters = {
@@ -26,6 +27,7 @@ export const DEFAULT_PARAMETERS: SignParameters = {
signerName: '',
fontFamily: 'Helvetica',
fontSize: 16,
+ textColor: '#000000',
};
const validateSignParameters = (parameters: SignParameters): boolean => {
diff --git a/frontend/src/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts
index cb23cfb60..cfca99b57 100644
--- a/frontend/src/hooks/useAppConfig.ts
+++ b/frontend/src/hooks/useAppConfig.ts
@@ -21,7 +21,6 @@ export interface AppConfig {
runningProOrHigher?: boolean;
runningEE?: boolean;
license?: string;
- GoogleDriveEnabled?: boolean;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
error?: string;
diff --git a/frontend/src/hooks/useGoogleDrivePicker.ts b/frontend/src/hooks/useGoogleDrivePicker.ts
new file mode 100644
index 000000000..5c9ead572
--- /dev/null
+++ b/frontend/src/hooks/useGoogleDrivePicker.ts
@@ -0,0 +1,98 @@
+/**
+ * React hook for Google Drive file picker
+ */
+
+import { useState, useCallback, useEffect } from 'react';
+import {
+ getGoogleDrivePickerService,
+ isGoogleDriveConfigured,
+ getGoogleDriveConfig,
+} from '../services/googleDrivePickerService';
+
+interface UseGoogleDrivePickerOptions {
+ multiple?: boolean;
+ mimeTypes?: string;
+}
+
+interface UseGoogleDrivePickerReturn {
+ isEnabled: boolean;
+ isLoading: boolean;
+ error: string | null;
+ openPicker: (options?: UseGoogleDrivePickerOptions) => Promise;
+}
+
+/**
+ * Hook to use Google Drive file picker
+ */
+export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn {
+ const [isEnabled, setIsEnabled] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ // Check if Google Drive is configured on mount
+ useEffect(() => {
+ const configured = isGoogleDriveConfigured();
+ setIsEnabled(configured);
+ }, []);
+
+ /**
+ * Initialize the Google Drive service (lazy initialization)
+ */
+ const initializeService = useCallback(async () => {
+ if (isInitialized) return;
+
+ const config = getGoogleDriveConfig();
+ if (!config) {
+ throw new Error('Google Drive is not configured');
+ }
+
+ const service = getGoogleDrivePickerService();
+ await service.initialize(config);
+ setIsInitialized(true);
+ }, [isInitialized]);
+
+ /**
+ * Open the Google Drive picker
+ */
+ const openPicker = useCallback(
+ async (options: UseGoogleDrivePickerOptions = {}): Promise => {
+ if (!isEnabled) {
+ setError('Google Drive is not configured');
+ return [];
+ }
+
+ try {
+ setIsLoading(true);
+ setError(null);
+
+ // Initialize service if needed
+ await initializeService();
+
+ // Open picker
+ const service = getGoogleDrivePickerService();
+ const files = await service.openPicker({
+ multiple: options.multiple ?? true,
+ mimeTypes: options.mimeTypes,
+ });
+
+ return files;
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to open Google Drive picker';
+ setError(errorMessage);
+ console.error('Google Drive picker error:', err);
+ return [];
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [isEnabled, initializeService]
+ );
+
+ return {
+ isEnabled,
+ isLoading,
+ error,
+ openPicker,
+ };
+}
diff --git a/frontend/src/services/googleDrivePickerService.ts b/frontend/src/services/googleDrivePickerService.ts
new file mode 100644
index 000000000..cb5b02b87
--- /dev/null
+++ b/frontend/src/services/googleDrivePickerService.ts
@@ -0,0 +1,295 @@
+/**
+ * Google Drive Picker Service
+ * Handles Google Drive file picker integration
+ */
+
+import { loadScript } from '../utils/scriptLoader';
+
+const SCOPES = 'https://www.googleapis.com/auth/drive.readonly';
+const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken';
+
+interface GoogleDriveConfig {
+ clientId: string;
+ apiKey: string;
+ appId: string;
+}
+
+interface PickerOptions {
+ multiple?: boolean;
+ mimeTypes?: string | null;
+}
+
+// Expandable mime types for Google Picker
+const expandableMimeTypes: Record = {
+ 'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'],
+};
+
+/**
+ * Convert file input accept attribute to Google Picker mime types
+ */
+function fileInputToGooglePickerMimeTypes(accept?: string): string | null {
+ if (!accept || accept === '' || accept.includes('*/*')) {
+ // Setting null will accept all supported mimetypes
+ return null;
+ }
+
+ const mimeTypes: string[] = [];
+ accept.split(',').forEach((part) => {
+ const trimmedPart = part.trim();
+ if (!(trimmedPart in expandableMimeTypes)) {
+ mimeTypes.push(trimmedPart);
+ return;
+ }
+
+ expandableMimeTypes[trimmedPart].forEach((mimeType) => {
+ mimeTypes.push(mimeType);
+ });
+ });
+
+ return mimeTypes.join(',').replace(/\s+/g, '');
+}
+
+class GoogleDrivePickerService {
+ private config: GoogleDriveConfig | null = null;
+ private tokenClient: any = null;
+ private accessToken: string | null = null;
+ private gapiLoaded = false;
+ private gisLoaded = false;
+
+ constructor() {
+ this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID);
+ }
+
+ /**
+ * Initialize the service with credentials
+ */
+ async initialize(config: GoogleDriveConfig): Promise {
+ this.config = config;
+
+ // Load Google APIs
+ await Promise.all([
+ this.loadGapi(),
+ this.loadGis(),
+ ]);
+ }
+
+ /**
+ * Load Google API client
+ */
+ private async loadGapi(): Promise {
+ if (this.gapiLoaded) return;
+
+ await loadScript({
+ src: 'https://apis.google.com/js/api.js',
+ id: 'gapi-script',
+ });
+
+ return new Promise((resolve) => {
+ window.gapi.load('client:picker', async () => {
+ await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest');
+ this.gapiLoaded = true;
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Load Google Identity Services
+ */
+ private async loadGis(): Promise {
+ if (this.gisLoaded) return;
+
+ await loadScript({
+ src: 'https://accounts.google.com/gsi/client',
+ id: 'gis-script',
+ });
+
+ if (!this.config) {
+ throw new Error('Google Drive config not initialized');
+ }
+
+ this.tokenClient = window.google.accounts.oauth2.initTokenClient({
+ client_id: this.config.clientId,
+ scope: SCOPES,
+ callback: () => {}, // Will be overridden during picker creation
+ });
+
+ this.gisLoaded = true;
+ }
+
+ /**
+ * Open the Google Drive picker
+ */
+ async openPicker(options: PickerOptions = {}): Promise {
+ if (!this.config) {
+ throw new Error('Google Drive service not initialized');
+ }
+
+ // Request access token
+ await this.requestAccessToken();
+
+ // Create and show picker
+ return this.createPicker(options);
+ }
+
+ /**
+ * Request access token from Google
+ */
+ private requestAccessToken(): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.tokenClient) {
+ reject(new Error('Token client not initialized'));
+ return;
+ }
+
+ this.tokenClient.callback = (response: any) => {
+ if (response.error !== undefined) {
+ reject(new Error(response.error));
+ return;
+ }
+ if(response.access_token == null){
+ reject(new Error("No acces token in response"));
+ }
+
+ this.accessToken = response.access_token;
+ sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? "");
+ resolve();
+ };
+
+ this.tokenClient.requestAccessToken({
+ prompt: this.accessToken === null ? 'consent' : '',
+ });
+ });
+ }
+
+ /**
+ * Create and display the Google Picker
+ */
+ private createPicker(options: PickerOptions): Promise {
+ return new Promise((resolve, reject) => {
+ if (!this.config || !this.accessToken) {
+ reject(new Error('Not initialized or no access token'));
+ return;
+ }
+
+ const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined);
+
+ const view1 = new window.google.picker.DocsView().setIncludeFolders(true);
+ if (mimeTypes !== null) {
+ view1.setMimeTypes(mimeTypes);
+ }
+
+ const view2 = new window.google.picker.DocsView()
+ .setIncludeFolders(true)
+ .setEnableDrives(true);
+ if (mimeTypes !== null) {
+ view2.setMimeTypes(mimeTypes);
+ }
+
+ const builder = new window.google.picker.PickerBuilder()
+ .setDeveloperKey(this.config.apiKey)
+ .setAppId(this.config.appId)
+ .setOAuthToken(this.accessToken)
+ .addView(view1)
+ .addView(view2)
+ .setCallback((data: any) => this.pickerCallback(data, resolve, reject));
+
+ if (options.multiple) {
+ builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED);
+ }
+
+ const picker = builder.build();
+ picker.setVisible(true);
+ });
+ }
+
+ /**
+ * Handle picker selection callback
+ */
+ private async pickerCallback(
+ data: any,
+ resolve: (files: File[]) => void,
+ reject: (error: Error) => void
+ ): Promise {
+ if (data.action === window.google.picker.Action.PICKED) {
+ try {
+ const files = await Promise.all(
+ data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => {
+ const fileId = pickedFile[window.google.picker.Document.ID];
+ const res = await window.gapi.client.drive.files.get({
+ fileId: fileId,
+ alt: 'media',
+ });
+
+ // Convert response body to File object
+ const file = new File(
+ [new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))],
+ pickedFile.name,
+ {
+ type: pickedFile.mimeType,
+ lastModified: pickedFile.lastModified,
+ }
+ );
+ return file;
+ })
+ );
+
+ resolve(files);
+ } catch (error) {
+ reject(error instanceof Error ? error : new Error('Failed to download files'));
+ }
+ } else if (data.action === window.google.picker.Action.CANCEL) {
+ resolve([]); // User cancelled, return empty array
+ }
+ }
+
+ /**
+ * Sign out and revoke access token
+ */
+ signOut(): void {
+ if (this.accessToken) {
+ sessionStorage.removeItem(SESSION_STORAGE_ID);
+ window.google?.accounts.oauth2.revoke(this.accessToken, () => {});
+ this.accessToken = null;
+ }
+ }
+}
+
+// Singleton instance
+let serviceInstance: GoogleDrivePickerService | null = null;
+
+/**
+ * Get or create the Google Drive picker service instance
+ */
+export function getGoogleDrivePickerService(): GoogleDrivePickerService {
+ if (!serviceInstance) {
+ serviceInstance = new GoogleDrivePickerService();
+ }
+ return serviceInstance;
+}
+
+/**
+ * Check if Google Drive credentials are configured
+ */
+export function isGoogleDriveConfigured(): boolean {
+ const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID;
+ const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY;
+ const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID;
+
+ return !!(clientId && apiKey && appId);
+}
+
+/**
+ * Get Google Drive configuration from environment variables
+ */
+export function getGoogleDriveConfig(): GoogleDriveConfig | null {
+ if (!isGoogleDriveConfigured()) {
+ return null;
+ }
+
+ return {
+ clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID,
+ apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY,
+ appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID,
+ };
+}
diff --git a/frontend/src/tools/Sign.tsx b/frontend/src/tools/Sign.tsx
index 807dd732d..f76621895 100644
--- a/frontend/src/tools/Sign.tsx
+++ b/frontend/src/tools/Sign.tsx
@@ -18,6 +18,7 @@ const Sign = (props: BaseToolProps) => {
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature();
const { consumeFiles, selectors } = useFileContext();
const { exportActions, getScrollState } = useViewer();
+ const { setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation();
// Track which signature mode was active for reactivation after save
const activeModeRef = useRef<'draw' | 'placement' | null>(null);
@@ -38,6 +39,11 @@ const Sign = (props: BaseToolProps) => {
handleSignaturePlacement();
}, [handleSignaturePlacement]);
+ const handleDeactivateSignature = useCallback(() => {
+ activeModeRef.current = null;
+ deactivateDrawMode();
+ }, [deactivateDrawMode]);
+
const base = useBaseTool(
'sign',
useSignParameters,
@@ -45,14 +51,18 @@ const Sign = (props: BaseToolProps) => {
props
);
- // Open viewer when files are selected
+ const hasOpenedViewer = useRef(false);
+
+ // Open viewer when files are selected (only once)
useEffect(() => {
- if (base.selectedFiles.length > 0) {
+ if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) {
setWorkbench('viewer');
+ hasOpenedViewer.current = true;
}
}, [base.selectedFiles.length, setWorkbench]);
+
// Sync signature configuration with context
useEffect(() => {
setSignatureConfig(base.params.parameters);
@@ -61,6 +71,10 @@ const Sign = (props: BaseToolProps) => {
// Save signed files to the system - apply signatures using EmbedPDF and replace original
const handleSaveToSystem = useCallback(async () => {
try {
+ // Unregister unsaved changes checker to prevent warning during apply
+ unregisterUnsavedChangesChecker();
+ setHasUnsavedChanges(false);
+
// Get the original file
let originalFile = null;
if (base.selectedFiles.length > 0) {
@@ -81,68 +95,63 @@ const Sign = (props: BaseToolProps) => {
}
// Use the signature flattening utility
- const success = await flattenSignatures({
+ const flattenResult = await flattenSignatures({
signatureApiRef,
getImageData,
exportActions,
selectors,
- consumeFiles,
originalFile,
getScrollState
});
- if (success) {
- console.log('✓ Signature flattening completed successfully');
+ if (flattenResult) {
+ // Now consume the files - this triggers the viewer reload
+ await consumeFiles(
+ flattenResult.inputFileIds,
+ [flattenResult.outputStirlingFile],
+ [flattenResult.outputStub]
+ );
// 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');
+ // Deactivate signature placement mode after everything completes
+ handleDeactivateSignature();
- // Reactivate the signature mode that was active before save
- if (activeModeRef.current === 'draw') {
- activateDrawMode();
- } else if (activeModeRef.current === 'placement') {
- handleSignaturePlacement();
- }
- }, 100);
- }, 200);
+ // File has been consumed - viewer should reload automatically via key prop
} else {
console.error('Signature flattening failed');
}
} catch (error) {
console.error('Error saving signed document:', error);
}
- }, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode]);
+ }, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker]);
const getSteps = () => {
const steps = [];
- // Step 1: Signature Configuration - Always visible
- steps.push({
- title: t('sign.steps.configure', 'Configure Signature'),
- isCollapsed: false,
- onCollapsedClick: undefined,
- content: (
-
- ),
- });
+ // Step 1: Signature Configuration - Only visible when file is loaded
+ if (base.selectedFiles.length > 0) {
+ steps.push({
+ title: t('sign.steps.configure', 'Configure Signature'),
+ isCollapsed: false,
+ onCollapsedClick: undefined,
+ content: (
+
+ ),
+ });
+ }
return steps;
};
diff --git a/frontend/src/utils/scriptLoader.ts b/frontend/src/utils/scriptLoader.ts
new file mode 100644
index 000000000..bf0b64ded
--- /dev/null
+++ b/frontend/src/utils/scriptLoader.ts
@@ -0,0 +1,55 @@
+/**
+ * Utility for dynamically loading external scripts
+ */
+
+interface ScriptLoadOptions {
+ src: string;
+ id?: string;
+ async?: boolean;
+ defer?: boolean;
+ onLoad?: () => void;
+}
+
+const loadedScripts = new Set();
+
+export function loadScript({ src, id, async = true, defer = false, onLoad }: ScriptLoadOptions): Promise {
+ return new Promise((resolve, reject) => {
+ // Check if already loaded
+ const scriptId = id || src;
+ if (loadedScripts.has(scriptId)) {
+ resolve();
+ return;
+ }
+
+ // Check if script already exists in DOM
+ const existingScript = id ? document.getElementById(id) : document.querySelector(`script[src="${src}"]`);
+ if (existingScript) {
+ loadedScripts.add(scriptId);
+ resolve();
+ return;
+ }
+
+ // Create and append script
+ const script = document.createElement('script');
+ script.src = src;
+ if (id) script.id = id;
+ script.async = async;
+ script.defer = defer;
+
+ script.onload = () => {
+ loadedScripts.add(scriptId);
+ if (onLoad) onLoad();
+ resolve();
+ };
+
+ script.onerror = () => {
+ reject(new Error(`Failed to load script: ${src}`));
+ };
+
+ document.head.appendChild(script);
+ });
+}
+
+export function isScriptLoaded(idOrSrc: string): boolean {
+ return loadedScripts.has(idOrSrc);
+}
diff --git a/frontend/src/utils/signatureFlattening.ts b/frontend/src/utils/signatureFlattening.ts
index fd7e701a6..1ff343155 100644
--- a/frontend/src/utils/signatureFlattening.ts
+++ b/frontend/src/utils/signatureFlattening.ts
@@ -1,7 +1,7 @@
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 { createProcessedFile, createChildStub } from '../contexts/file/fileActions';
+import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext';
import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge';
interface MinimalFileContextSelectors {
@@ -17,13 +17,18 @@ interface SignatureFlatteningOptions {
saveAsCopy: () => Promise;
};
selectors: MinimalFileContextSelectors;
- consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise;
originalFile?: StirlingFile;
getScrollState: () => { currentPage: number; totalPages: number };
}
-export async function flattenSignatures(options: SignatureFlatteningOptions): Promise {
- const { signatureApiRef, getImageData, exportActions, selectors, consumeFiles, originalFile, getScrollState } = options;
+export interface SignatureFlatteningResult {
+ inputFileIds: FileId[];
+ outputStirlingFile: StirlingFile;
+ outputStub: StirlingFileStub;
+}
+
+export async function flattenSignatures(options: SignatureFlatteningOptions): Promise {
+ const { signatureApiRef, getImageData, exportActions, selectors, originalFile, getScrollState } = options;
try {
// Step 1: Extract all annotations from EmbedPDF before export
@@ -66,8 +71,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
}
}
- 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) {
@@ -85,7 +88,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
// Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations)
if (!exportActions) {
console.error('No export actions available');
- return false;
+ return null;
}
const pdfArrayBuffer = await exportActions.saveAsCopy();
@@ -111,7 +114,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
if (!currentFile) {
console.error('No file available to replace');
- return false;
+ return null;
}
let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' });
@@ -119,7 +122,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
// 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
@@ -150,7 +152,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
const pages = pdfDoc.getPages();
-
for (const pageData of allAnnotations) {
const { pageIndex, annotations } = pageData;
@@ -189,6 +190,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
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));
@@ -215,6 +217,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
console.error('Failed to render image annotation:', imageError);
}
} else if (annotation.content || annotation.text) {
+ console.warn('Rendering text annotation instead');
// Handle text annotations
page.drawText(annotation.content || annotation.text, {
x: pdfX,
@@ -287,23 +290,30 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
const record = selectors.getStirlingFileStub(currentFile.fileId);
if (!record) {
console.error('No file record found for:', currentFile.fileId);
- return false;
+ return null;
}
- // Create output stub and file
- const outputStub = createNewStirlingFileStub(signedFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
+ // Create output stub and file as a child of the original (increments version)
+ const outputStub = createChildStub(
+ record,
+ { toolId: 'sign', timestamp: Date.now() },
+ signedFile,
+ 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 the flattened file data for consumption by caller
+ return {
+ inputFileIds,
+ outputStirlingFile,
+ outputStub
+ };
}
- return false;
+ return null;
} catch (error) {
console.error('Error flattening signatures:', error);
- return false;
+ return null;
}
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 000000000..9ae7c67c8
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,6 @@
+{
+ "name": "Stirling-PDF",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {}
+}