From 340006ceea649a0130171c5392916524192952dc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 16 Dec 2025 14:55:53 +0000 Subject: [PATCH 1/3] Add Sign Up functionality to desktop app (#5244) # Description of Changes Adds Sign Up with email to desktop app. SSO sign up will come in a future PR. --- frontend/src-tauri/Cargo.lock | 93 ++++++++++++++++ frontend/src-tauri/Cargo.toml | 3 +- frontend/src-tauri/capabilities/default.json | 4 +- frontend/src-tauri/src/lib.rs | 37 ++++++- frontend/src-tauri/tauri.conf.json | 7 ++ .../SetupWizard/SaaSLoginScreen.tsx | 16 +++ .../SetupWizard/SaaSSignupScreen.tsx | 104 ++++++++++++++++++ .../desktop/components/SetupWizard/index.tsx | 68 ++++++++++++ frontend/src/desktop/constants/connection.ts | 6 + frontend/src/desktop/services/authService.ts | 63 ++++++++++- 10 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 9719752dc..2689b6b98 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -589,6 +589,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -716,6 +736,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -908,6 +934,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1626,6 +1661,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -2820,6 +2861,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3651,6 +3702,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4256,6 +4317,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-log", @@ -4615,6 +4677,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.4" @@ -4735,6 +4818,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.17", "tracing", "windows-sys 0.60.2", @@ -4954,6 +5038,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index dc84ad8a2..455436cdf 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" tauri-plugin-http = "2.4.4" -tauri-plugin-single-instance = "2.0.1" +tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] } tauri-plugin-store = "2.1.0" tauri-plugin-opener = "2.0.0" +tauri-plugin-deep-link = "2.4.5" keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] } tokio = { version = "1.0", features = ["time", "sync"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index b992b3221..445e1d30a 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -19,6 +19,8 @@ { "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] - } + }, + "opener:default", + "shell:allow-open" ] } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a08587cc1..61cbd6d43 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use tauri::{Manager, RunEvent, WindowEvent, Emitter}; +use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; mod utils; mod commands; @@ -28,6 +28,17 @@ use commands::{ }; use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; +use tauri_plugin_deep_link::DeepLinkExt; + +fn dispatch_deep_link(app: &AppHandle, url: &str) { + add_log(format!("🔗 Dispatching deep link: {}", url)); + let _ = app.emit("deep-link", url.to_string()); + + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + let _ = window.unminimize(); + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -42,6 +53,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { // This callback runs when a second instance tries to start @@ -78,6 +90,29 @@ pub fn run() { } } + { + let app_handle = app.handle(); + // On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux + #[cfg(any(target_os = "linux", target_os = "windows"))] + if let Err(err) = app_handle.deep_link().register_all() { + add_log(format!("⚠️ Failed to register deep link handler: {}", err)); + } + + if let Ok(Some(urls)) = app_handle.deep_link().get_current() { + let initial_handle = app_handle.clone(); + for url in urls { + dispatch_deep_link(&initial_handle, url.as_str()); + } + } + + let event_app_handle = app_handle.clone(); + app_handle.deep_link().on_open_url(move |event| { + for url in event.urls() { + dispatch_deep_link(&event_app_handle, url.as_str()); + } + }); + } + // Start backend immediately, non-blocking let app_handle = app.handle().clone(); diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index b10fec0e5..43546413c 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -77,6 +77,13 @@ }, "fs": { "requireLiteralLeadingDot": false + }, + "deep-link": { + "desktop": { + "schemes": [ + "stirlingpdf" + ] + } } } } diff --git a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index ab82bde1e..6acd28858 100644 --- a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -14,6 +14,7 @@ interface SaaSLoginScreenProps { onLogin: (username: string, password: string) => Promise; onOAuthSuccess: (userInfo: UserInfo) => Promise; onSelfHostedClick: () => void; + onSwitchToSignup: () => void; loading: boolean; error: string | null; } @@ -23,6 +24,7 @@ export const SaaSLoginScreen: React.FC = ({ onLogin, onOAuthSuccess, onSelfHostedClick, + onSwitchToSignup, loading, error, }) => { @@ -89,6 +91,20 @@ export const SaaSLoginScreen: React.FC = ({ submitButtonText={t('setup.login.submit', 'Login')} /> +
+ +
+ ); diff --git a/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx new file mode 100644 index 000000000..6ad330926 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LoginHeader from '@app/routes/login/LoginHeader'; +import ErrorMessage from '@app/routes/login/ErrorMessage'; +import SignupForm from '@app/routes/signup/SignupForm'; +import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'; +import { authService } from '@app/services/authService'; +import '@app/routes/authShared/auth.css'; + +interface SaaSSignupScreenProps { + loading: boolean; + error: string | null; + onLogin: (username: string, password: string) => Promise; + onSwitchToLogin: () => void; +} + +export const SaaSSignupScreen: React.FC = ({ + loading, + error, + onLogin: _onLogin, + onSwitchToLogin: _onSwitchToLogin, +}) => { + const { t } = useTranslation(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [validationError, setValidationError] = useState(null); + const [signupFieldErrors, setSignupFieldErrors] = useState({}); + const [signupSuccessMessage, setSignupSuccessMessage] = useState(null); + const [isSignupSubmitting, setIsSignupSubmitting] = useState(false); + const { validateSignupForm } = useSignupFormValidation(); + + const displayError = error || validationError; + + const handleSignupSubmit = async () => { + setValidationError(null); + setSignupSuccessMessage(null); + setSignupFieldErrors({}); + + const validation = validateSignupForm(email, password, confirmPassword); + if (!validation.isValid) { + setValidationError(validation.error); + setSignupFieldErrors(validation.fieldErrors || {}); + return; + } + + try { + setIsSignupSubmitting(true); + await authService.signUpSaas(email.trim(), password); + setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.')); + setSignupFieldErrors({}); + setValidationError(null); + } catch (err) { + setSignupSuccessMessage(null); + const message = err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }); + setValidationError(message); + } finally { + setIsSignupSubmitting(false); + } + }; + + return ( + <> + + + + {signupSuccessMessage && ( +
+

{signupSuccessMessage}

+
+ )} + + { + setEmail(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + setPassword={(value) => { + setPassword(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + setConfirmPassword={(value) => { + setConfirmPassword(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + onSubmit={handleSignupSubmit} + isSubmitting={loading || isSignupSubmitting} + fieldErrors={signupFieldErrors} + showName={false} + showTerms={false} + /> + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index ac4fa2369..9fbd0e6cc 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -2,16 +2,20 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout'; import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen'; +import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen'; import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen'; import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen'; import { ServerConfig, connectionModeService } from '@app/services/connectionModeService'; import { authService, UserInfo } from '@app/services/authService'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { STIRLING_SAAS_URL } from '@desktop/constants/connection'; +import { listen } from '@tauri-apps/api/event'; +import { useEffect } from 'react'; import '@app/routes/authShared/auth.css'; enum SetupStep { SaaSLogin, + SaaSSignup, ServerSelection, SelfHostedLogin, } @@ -80,6 +84,16 @@ export const SetupWizard: React.FC = ({ onComplete }) => { setActiveStep(SetupStep.ServerSelection); }; + const handleSwitchToSignup = () => { + setError(null); + setActiveStep(SetupStep.SaaSSignup); + }; + + const handleSwitchToLogin = () => { + setError(null); + setActiveStep(SetupStep.SaaSLogin); + }; + const handleServerSelection = (config: ServerConfig) => { setServerConfig(config); setError(null); @@ -128,6 +142,48 @@ export const SetupWizard: React.FC = ({ onComplete }) => { } }; + useEffect(() => { + const unsubscribePromise = listen('deep-link', async (event) => { + const url = event.payload; + if (!url) return; + + try { + const parsed = new URL(url); + + // Supabase sends tokens in the URL hash + const hash = parsed.hash.replace(/^#/, ''); + const params = new URLSearchParams(hash); + const accessToken = params.get('access_token'); + const type = params.get('type') || parsed.searchParams.get('type'); + + if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) { + return; + } + + if (!accessToken) { + console.error('[SetupWizard] Deep link missing access_token'); + return; + } + + setLoading(true); + setError(null); + + await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL); + await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL); + tauriBackendService.startBackend().catch(console.error); + onComplete(); + } catch (err) { + console.error('[SetupWizard] Failed to handle deep link', err); + setError(err instanceof Error ? err.message : 'Failed to complete signup'); + setLoading(false); + } + }); + + return () => { + void unsubscribePromise.then((unsub) => unsub()); + }; + }, [onComplete, serverConfig?.url]); + const handleBack = () => { setError(null); if (activeStep === SetupStep.SelfHostedLogin) { @@ -135,6 +191,8 @@ export const SetupWizard: React.FC = ({ onComplete }) => { } else if (activeStep === SetupStep.ServerSelection) { setActiveStep(SetupStep.SaaSLogin); setServerConfig({ url: STIRLING_SAAS_URL }); + } else if (activeStep === SetupStep.SaaSSignup) { + setActiveStep(SetupStep.SaaSLogin); } }; @@ -147,11 +205,21 @@ export const SetupWizard: React.FC = ({ onComplete }) => { onLogin={handleSaaSLogin} onOAuthSuccess={handleSaaSLoginOAuth} onSelfHostedClick={handleSelfHostedClick} + onSwitchToSignup={handleSwitchToSignup} loading={loading} error={error} /> )} + {activeStep === SetupStep.SaaSSignup && ( + + )} + {activeStep === SetupStep.ServerSelection && ( { + if (!accessToken || !accessToken.trim()) { + throw new Error('Invalid access token'); + } + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + await this.saveTokenEverywhere(accessToken); + + const userInfo = await this.fetchSupabaseUserInfo(serverUrl, accessToken); + + await invoke('save_user_info', { + username: userInfo.username, + email: userInfo.email || null, + }); + + this.setAuthStatus('authenticated', userInfo); + return userInfo; + } + + async signUpSaas(email: string, password: string): Promise { + if (!STIRLING_SAAS_URL) { + throw new Error('VITE_SAAS_SERVER_URL is not configured'); + } + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK); + const signupUrl = `${STIRLING_SAAS_URL.replace(/\/$/, '')}/auth/v1/signup?redirect_to=${redirectParam}`; + + try { + const response = await axios.post( + signupUrl, + { email, password, email_redirect_to: DESKTOP_DEEP_LINK_CALLBACK }, + { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + }, + } + ); + + if (response.status >= 400) { + throw new Error('Sign up failed'); + } + } catch (error) { + if (axios.isAxiosError(error)) { + const message = + error.response?.data?.error_description || + error.response?.data?.msg || + error.response?.data?.message || + error.message; + throw new Error(message || 'Sign up failed'); + } + throw error instanceof Error ? error : new Error('Sign up failed'); + } + } + async login(serverUrl: string, username: string, password: string): Promise { try { console.log('Logging in to:', serverUrl); From 195b1472e41a736d6be17071662d17aa7eae8afb Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:52:47 +0000 Subject: [PATCH 2/3] Bug/v2/viewer annotations (#5245) Show uneditable annotations on viewer show editable annotations layer when in annotation tools (sign, add image, add text) Remove draw tool from viewer (this is replaced wholesale in an upcoming PR so it wasn't worth doing the work to ensure it worked with the new annotation layer set up_) refactoring work, mostly renaming variables we can use for all annotation based tools that had sign specific names. remove "tools" tooltip --------- Co-authored-by: Claude Sonnet 4.5 --- .../components/shared/AllToolsNavButton.tsx | 35 ++-- .../rightRail/ViewerAnnotationControls.tsx | 188 +----------------- .../core/components/viewer/EmbedPdfViewer.tsx | 19 +- .../core/components/viewer/LocalEmbedPDF.tsx | 7 +- frontend/src/core/contexts/ViewerContext.tsx | 6 - 5 files changed, 31 insertions(+), 224 deletions(-) diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx index 1608d5bd3..3ba3bb70b 100644 --- a/frontend/src/core/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Tooltip } from '@app/components/shared/Tooltip'; import AppsIcon from '@mui/icons-material/AppsRounded'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; @@ -11,13 +10,11 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess interface AllToolsNavButtonProps { activeButton: string; setActiveButton: (id: string) => void; - tooltipPosition?: 'left' | 'right' | 'top' | 'bottom'; } const AllToolsNavButton: React.FC = ({ activeButton, setActiveButton, - tooltipPosition = 'right' }) => { const { t } = useTranslation(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); @@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC = ({ }; return ( - -
- } - label={t("quickAccess.allTools", "Tools")} - isActive={isActive} - onClick={handleNavClick} - href={navProps.href} - ariaLabel={t("quickAccess.allTools", "Tools")} - textClassName="all-tools-text" - component="a" - /> -
-
+
+ } + label={t("quickAccess.allTools", "Tools")} + isActive={isActive} + onClick={handleNavClick} + href={navProps.href} + ariaLabel={t("quickAccess.allTools", "Tools")} + textClassName="all-tools-text" + component="a" + /> +
); }; diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index cd95b90b9..0c9cfc3dd 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -1,15 +1,9 @@ -import React, { useState, useEffect } from 'react'; -import { ActionIcon, Popover } from '@mantine/core'; +import React from 'react'; +import { ActionIcon } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import LocalIcon from '@app/components/shared/LocalIcon'; import { Tooltip } from '@app/components/shared/Tooltip'; import { ViewerContext } from '@app/contexts/ViewerContext'; -import { useSignature } from '@app/contexts/SignatureContext'; -import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker'; -import { useFileState, useFileContext } from '@app/contexts/FileContext'; -import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils'; -import { createProcessedFile } from '@app/contexts/file/fileActions'; -import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext'; import { useNavigationState } from '@app/contexts/NavigationContext'; import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; @@ -23,32 +17,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const { t } = useTranslation(); const { sidebarRefs } = useSidebarContext(); const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); - const [selectedColor, setSelectedColor] = useState('#000000'); - const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); - const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false); // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); - // Signature context for accessing drawing API - 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) { - viewerContext.setAnnotationMode(false); - } - }, [currentView, viewerContext]); - // Don't show any annotation controls in sign mode if (isSignMode) { return null; @@ -65,7 +41,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false onClick={() => { viewerContext?.toggleAnnotationsVisibility(); }} - disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode} + disabled={disabled || currentView !== 'viewer'} > - - {/* Annotation Mode Toggle with Drawing Controls */} - {viewerContext?.isAnnotationMode ? ( - // When active: Show color picker on hover -
setIsHoverColorPickerOpen(true)} - onMouseLeave={() => setIsHoverColorPickerOpen(false)} - style={{ display: 'inline-flex' }} - > - setIsHoverColorPickerOpen(false)} - position="left" - withArrow - shadow="md" - offset={8} - > - - { - viewerContext?.toggleAnnotationMode(); - setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off - // Deactivate drawing tool when exiting annotation mode - if (signatureApiRef?.current) { - try { - signatureApiRef.current.deactivateTools(); - } catch (error) { - console.log('Signature API not ready:', error); - } - } - }} - disabled={disabled} - aria-label="Drawing mode active" - > - - - - -
-
-
Drawing Color
- { - setIsHoverColorPickerOpen(false); // Close hover picker - setIsColorPickerOpen(true); // Open main color picker modal - }} - /> -
-
-
-
-
- ) : ( - // When inactive: Show "Draw" tooltip - - { - viewerContext?.toggleAnnotationMode(); - // Activate ink drawing tool when entering annotation mode - if (signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.activateDrawMode(); - signatureApiRef.current.updateDrawSettings(selectedColor, 2); - } catch (error) { - console.log('Signature API not ready:', error); - } - } - }} - disabled={disabled} - aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'} - > - - - - )} - - {/* Save PDF with Annotations */} - - { - if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') { - try { - const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy(); - if (pdfArrayBuffer) { - // Create new File object with flattened annotations - const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' }); - - // Get the original file name or use a default - const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf'; - const newFile = new File([blob], originalFileName, { type: 'application/pdf' }); - - // Replace the current file in context with the saved version (exact same logic as Sign tool) - if (activeFiles.length > 0) { - // Generate thumbnail and metadata for the saved file - const thumbnailResult = await generateThumbnailWithMetadata(newFile); - const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail); - - // Get current file info - const currentFileIds = state.files.ids; - if (currentFileIds.length > 0) { - const currentFileId = currentFileIds[0]; - const currentRecord = selectors.getStirlingFileStub(currentFileId); - - if (!currentRecord) { - console.error('No file record found for:', currentFileId); - return; - } - - // Create output stub and file (exact same as Sign tool) - const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata); - const outputStirlingFile = createStirlingFile(newFile, outputStub.id); - - // Replace the original file with the saved version - await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]); - } - } - } - } catch (error) { - console.error('Error saving PDF:', error); - } - } - }} - disabled={disabled} - > - - - - - {/* Color Picker Modal */} - setIsColorPickerOpen(false)} - selectedColor={selectedColor} - onColorChange={(color) => { - setSelectedColor(color); - // Update drawing tool color if annotation mode is active - if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.updateDrawSettings(color, 2); - } catch (error) { - console.log('Unable to update drawing settings:', error); - } - } - }} - title="Choose Drawing Color" - /> ); } diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 94bff5a35..8e66f0b5c 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -51,6 +51,7 @@ const EmbedPdfViewerContent = ({ getScrollState, getRotationState, isAnnotationMode, + setAnnotationMode, isAnnotationsVisible, exportActions, } = useViewer(); @@ -82,15 +83,18 @@ const EmbedPdfViewerContent = ({ // Navigation guard for unsaved changes const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); - // Check if we're in signature mode OR viewer annotation mode + // Check if we're in an annotation tool const { selectedTool } = useNavigationState(); - // Tools that use the stamp/signature placement system with hover preview - const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; + // Tools that require the annotation layer (Sign, Add Text, Add Image) + const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; + + // Sync isAnnotationMode in ViewerContext with current tool + useEffect(() => { + setAnnotationMode(isInAnnotationTool); + }, [isInAnnotationTool, setAnnotationMode]); - // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations - const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible; const isPlacementOverlayActive = Boolean( - isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig + isInAnnotationTool && isPlacementMode && signatureConfig ); // Track which file tab is active @@ -333,7 +337,8 @@ const EmbedPdfViewerContent = ({ key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)} file={effectiveFile.file} url={effectiveFile.url} - enableAnnotations={shouldEnableAnnotations} + enableAnnotations={isAnnotationMode} + showBakedAnnotations={isAnnotationsVisible} signatureApiRef={signatureApiRef as React.RefObject} historyApiRef={historyApiRef as React.RefObject} onSignatureAdded={() => { diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 0b79604fd..8f20cd96f 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -52,12 +52,13 @@ interface LocalEmbedPDFProps { file?: File | Blob; url?: string | null; enableAnnotations?: boolean; + showBakedAnnotations?: boolean; onSignatureAdded?: (annotation: any) => void; signatureApiRef?: React.RefObject; historyApiRef?: React.RefObject; } -export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) { +export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) { const { t } = useTranslation(); const [pdfUrl, setPdfUrl] = useState(null); const [, setAnnotations] = useState>([]); @@ -100,7 +101,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur }), createPluginRegistration(RenderPluginPackage, { withForms: true, - withAnnotations: true, + withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF }), // Register interaction manager (required for zoom and selection features) @@ -166,7 +167,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register print plugin for printing PDFs createPluginRegistration(PrintPluginPackage), ]; - }, [pdfUrl]); + }, [pdfUrl, enableAnnotations, showBakedAnnotations]); // Initialize the engine with the React hook - use local WASM for offline support const { engine, isLoading, error } = usePdfiumEngine({ diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 9217511ef..aa2dc497b 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -95,7 +95,6 @@ interface ViewerContextType { // Annotation/drawing mode for viewer isAnnotationMode: boolean; setAnnotationMode: (enabled: boolean) => void; - toggleAnnotationMode: () => void; // Active file index for multi-file viewing activeFileIndex: number; @@ -230,10 +229,6 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsAnnotationModeState(enabled); }; - const toggleAnnotationMode = () => { - setIsAnnotationModeState(prev => !prev); - }; - // State getters - read from bridge refs const getScrollState = (): ScrollState => { return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 }; @@ -318,7 +313,6 @@ export const ViewerProvider: React.FC = ({ children }) => { toggleAnnotationsVisibility, isAnnotationMode, setAnnotationMode, - toggleAnnotationMode, // Active file index activeFileIndex, From 93ed05b0549af444712f06f7a29d2df372eded47 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:02:22 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Only=20allow=20dual=20page=20view=20when=20?= =?UTF-8?q?there=20is=20more=20than=20one=20page,=20update=20to=E2=80=A6?= =?UTF-8?q?=20(#5246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/viewer/PdfViewerToolbar.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx index a2e36d4e2..258b8ba43 100644 --- a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx +++ b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Button, Paper, Group, NumberInput } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useViewer } from '@app/contexts/ViewerContext'; +import { Tooltip } from '@app/components/shared/Tooltip'; import FirstPageIcon from '@mui/icons-material/FirstPage'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; @@ -209,21 +210,27 @@ export function PdfViewerToolbar({ {/* Dual Page Toggle */} - + + {/* Zoom Controls */}