Merge branch 'V2' of github.com:Stirling-Tools/Stirling-PDF into V2-legacy-ui

This commit is contained in:
EthanHealy01 2025-10-09 15:42:36 +01:00
commit 135a9b40d7
35 changed files with 1111 additions and 714 deletions

View File

@ -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() {

View File

@ -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

View File

@ -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();

View File

@ -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",

View File

@ -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

View File

@ -422,10 +422,6 @@
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
</div>
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
</div>
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
</div>
</div>
<div class="selected-files flex-wrap"></div>
@ -443,16 +439,4 @@
</div>
</div>
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
<div th:if="${@GoogleDriveEnabled == true}" >
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
<script th:inline="javascript">
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
</script>
</div>
</th:block>

View File

@ -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) {

View File

@ -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
- **Production**: Remove backend port exposure, use only frontend proxy

View File

@ -47,6 +47,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:

View File

@ -44,6 +44,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:

View File

@ -46,6 +46,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:

View File

@ -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",

View File

@ -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",

View File

@ -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": {

View File

@ -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<FileManagerProps> = ({ 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';

View File

@ -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<DrawingCanvasProps> = ({
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
onDrawingComplete,
disabled = false,
width = 400,
height = 150,
modalWidth = 800,
modalHeight = 400,
additionalButtons
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const visibleModalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(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<HTMLCanvasElement>) => {
if (!canvasRef.current || disabled) return;
setIsDrawing(true);
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
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<HTMLCanvasElement>) => {
if (!isDrawing || !canvasRef.current || disabled) return;
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
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<HTMLCanvasElement>) => {
if (!visibleModalCanvasRef.current || !modalCanvasRef.current) return;
setIsModalDrawing(true);
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both the visible modal canvas and hidden canvas
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
}
});
}, [selectedColor, penSize]);
const drawModal = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isModalDrawing || !visibleModalCanvasRef.current || !modalCanvasRef.current) return;
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both canvases
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
});
}, [isModalDrawing]);
const stopModalDrawing = useCallback(() => {
if (!isModalDrawing) return;
setIsModalDrawing(false);
// Sync the canvases and update signature data (only when drawing stops)
if (modalCanvasRef.current) {
const dataURL = modalCanvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
// Also update the small canvas display
if (canvasRef.current) {
const smallCtx = canvasRef.current.getContext('2d');
if (smallCtx) {
const img = new Image();
img.onload = () => {
smallCtx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
smallCtx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
};
img.src = dataURL;
// 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 (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={500}>Draw your signature</Text>
<Group gap="lg">
<div>
<Text size="sm" fw={500} mb="xs" ta="center">Color</Text>
<Group justify="center">
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Group>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
disabled={disabled}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
<div style={{ paddingTop: '24px' }}>
<Button
variant="light"
size="compact-sm"
onClick={openModal}
disabled={disabled}
>
Expand
</Button>
</div>
</Group>
</Group>
<Text fw={500}>Draw your signature</Text>
<canvas
ref={canvasRef}
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'crosshair',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onClick={disabled ? undefined : openModal}
/>
<Group justify="space-between">
<div>
{additionalButtons}
</div>
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={clearCanvas}
disabled={disabled}
>
Clear
</Button>
</Group>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
</Text>
</Stack>
</Paper>
{/* Hidden canvas for modal synchronization */}
<canvas
ref={modalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{ display: 'none' }}
/>
{/* Modal for larger signature canvas */}
<Modal
opened={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Draw Your Signature"
size="xl"
centered
>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
<Stack gap="md">
{/* Color and Pen Size picker */}
<Paper withBorder p="sm">
<Group gap="lg" align="flex-end">
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</Group>
</Paper>
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<Popover
opened={colorPickerOpen}
onChange={setColorPickerOpen}
position="bottom-start"
withArrow
withinPortal={false}
>
<Popover.Target>
<div>
<ColorSwatchButton
color={selectedColor}
onClick={() => setColorPickerOpen(!colorPickerOpen)}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={(color) => {
onColorSwatchClick();
updatePenColor(color);
}}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
/>
</Popover.Dropdown>
</Popover>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={(size) => {
onPenSizeChange(size);
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</div>
<Paper withBorder p="md">
<canvas
ref={visibleModalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'crosshair',
backgroundColor: '#ffffff',
width: '100%',
maxWidth: `${modalWidth}px`,
height: 'auto',
}}
onMouseDown={startModalDrawing}
onMouseMove={drawModal}
onMouseUp={stopModalDrawing}
onMouseLeave={stopModalDrawing}
/>
</Paper>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
<Group justify="space-between">
<Button
variant="subtle"
color="red"
onClick={clearModalCanvas}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
Clear Canvas
</Button>
<Group gap="sm">
<Button
variant="subtle"
onClick={() => setIsModalOpen(false)}
>
Cancel
</Button>
<Button
onClick={saveModalSignature}
>
Save Signature
</Button>
</Group>
</Group>
<Button onClick={closeModal}>
Done
</Button>
</div>
</Stack>
</Modal>
</>
);
};
export default DrawingCanvas;
export default DrawingCanvas;

View File

@ -48,7 +48,7 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{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')}
</Text>
</Stack>
);

View File

@ -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<TextInputWithFontProps> = ({
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = '#000000',
onTextColorChange,
disabled = false,
label,
placeholder
@ -28,6 +33,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
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<TextInputWithFontProps> = ({
{ 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 (
<Stack gap="sm">
@ -66,61 +72,101 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
allowDeselect={false}
/>
{/* Font Size */}
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-72)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
{/* Font Size and Color */}
<Group grow>
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 72) {
onFontSizeChange(size);
// 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}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Text Color Picker */}
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
rightSection={
<Box
style={{
width: 24,
height: 24,
backgroundColor: textColor,
border: '1px solid #ccc',
borderRadius: 4,
cursor: disabled ? 'default' : 'pointer'
}}
/>
}
/>
</Box>
)}
</Group>
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 72) {
setFontSizeInput(fontSize.toString());
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Color Picker Modal */}
{onTextColorChange && (
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor}
onColorChange={onTextColorChange}
/>
)}
</Stack>
);
};

View File

@ -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<FileSourceButtonsProps> = ({
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<FileSourceButtonsProps> = ({
</Button>
<Button
variant={buttonProps.variant('drive')}
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<CloudIcon />}
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')}
</Button>

View File

@ -51,7 +51,6 @@ const Overview: React.FC = () => {
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;

View File

@ -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}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}

View File

@ -1,8 +1,9 @@
import { useState, useEffect } from 'react';
import { useTranslation } from "react-i18next";
import { Stack, Button, Text, Alert, Tabs } from '@mantine/core';
import { Stack, Button, Text, Alert, Tabs, SegmentedControl } from '@mantine/core';
import { SignParameters } from "../../../hooks/tools/sign/useSignParameters";
import { SuggestedToolsSection } from "../shared/SuggestedToolsSection";
import { useSignature } from "../../../contexts/SignatureContext";
// Import the new reusable components
import { DrawingCanvas } from "../../annotation/shared/DrawingCanvas";
@ -35,12 +36,14 @@ const SignSettings = ({
onSave
}: SignSettingsProps) => {
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<string | null>(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={
<Button
@ -195,7 +223,7 @@ const SignSettings = ({
variant="filled"
disabled={disabled || !canvasSignatureData}
>
Update and Place
{t('sign.updateAndPlace', 'Update and Place')}
</Button>
}
/>
@ -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() !== '')) && (
<SegmentedControl
value={interactionMode}
onChange={(value) => {
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 */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
{parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'}
{parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'}
{parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'}
{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.')}
</Text>
</Alert>

View File

@ -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'
}}>
<LocalEmbedPDF
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
url={effectiveFile.url}
enableAnnotations={shouldEnableAnnotations}

View File

@ -314,7 +314,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer

View File

@ -1,34 +1,30 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { useSignature } from '../../contexts/SignatureContext';
import { useViewer } from '../../contexts/ViewerContext';
export interface SignatureAPI {
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
addTextSignature: (text: string, x: number, y: number, pageIndex: number) => void;
activateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
deleteAnnotation: (annotationId: string, pageIndex: number) => void;
updateDrawSettings: (color: string, size: number) => void;
deactivateTools: () => void;
applySignatureFromParameters: (params: SignParameters) => void;
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
}
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
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<SignatureAPI>(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<SignatureAPI>(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<SignatureAPI>(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<SignatureAPI>(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<SignatureAPI>(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<any[]> => {
if (!annotationApi || !annotationApi.getPageAnnotations) {
console.warn('getPageAnnotations not available');

View File

@ -39,6 +39,7 @@ interface FileManagerContextValue {
onAddToRecents: (file: StirlingFileStub) => void;
onUnzipFile: (file: StirlingFileStub) => Promise<void>;
onNewFilesSelect: (files: File[]) => void;
onGoogleDriveSelect: (files: File[]) => void;
// External props
recentFiles: StirlingFileStub[];
@ -546,6 +547,19 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}, [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<FileManagerProviderProps> = ({
onAddToRecents: handleAddToRecents,
onUnzipFile: handleUnzipFile,
onNewFilesSelect,
onGoogleDriveSelect: handleGoogleDriveSelect,
// External props
recentFiles,
@ -656,6 +671,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
handleAddToRecents,
handleUnzipFile,
onNewFilesSelect,
handleGoogleDriveSelect,
recentFiles,
isFileSupported,
modalHeight,

View File

@ -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
}
};
}

View File

@ -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 => {

View File

@ -21,7 +21,6 @@ export interface AppConfig {
runningProOrHigher?: boolean;
runningEE?: boolean;
license?: string;
GoogleDriveEnabled?: boolean;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
error?: string;

View File

@ -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<File[]>;
}
/**
* 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<string | null>(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<File[]> => {
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,
};
}

View File

@ -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<string, string[]> = {
'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<void> {
this.config = config;
// Load Google APIs
await Promise.all([
this.loadGapi(),
this.loadGis(),
]);
}
/**
* Load Google API client
*/
private async loadGapi(): Promise<void> {
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<void> {
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<File[]> {
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<void> {
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<File[]> {
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<void> {
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,
};
}

View File

@ -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: (
<SignSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
onActivateDrawMode={handleActivateDrawMode}
onActivateSignaturePlacement={handleActivateSignaturePlacement}
onDeactivateSignature={deactivateDrawMode}
onUpdateDrawSettings={updateDrawSettings}
onUndo={undo}
onRedo={redo}
onSave={handleSaveToSystem}
/>
),
});
// 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: (
<SignSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
onActivateDrawMode={handleActivateDrawMode}
onActivateSignaturePlacement={handleActivateSignaturePlacement}
onDeactivateSignature={handleDeactivateSignature}
onUpdateDrawSettings={updateDrawSettings}
onUndo={undo}
onRedo={redo}
onSave={handleSaveToSystem}
/>
),
});
}
return steps;
};

View File

@ -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<string>();
export function loadScript({ src, id, async = true, defer = false, onLoad }: ScriptLoadOptions): Promise<void> {
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);
}

View File

@ -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<ArrayBuffer | null>;
};
selectors: MinimalFileContextSelectors;
consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise<FileId[]>;
originalFile?: StirlingFile;
getScrollState: () => { currentPage: number; totalPages: number };
}
export async function flattenSignatures(options: SignatureFlatteningOptions): Promise<boolean> {
const { signatureApiRef, getImageData, exportActions, selectors, consumeFiles, originalFile, getScrollState } = options;
export interface SignatureFlatteningResult {
inputFileIds: FileId[];
outputStirlingFile: StirlingFile;
outputStub: StirlingFileStub;
}
export async function flattenSignatures(options: SignatureFlatteningOptions): Promise<SignatureFlatteningResult | null> {
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;
}
}

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "Stirling-PDF",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}