mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
Merge remote-tracking branch 'origin/main' into feature/V2/redact
This commit is contained in:
commit
b06cec2648
93
frontend/src-tauri/Cargo.lock
generated
93
frontend/src-tauri/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"] }
|
||||
|
||||
@ -19,6 +19,8 @@
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
},
|
||||
"opener:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -77,6 +77,13 @@
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"stirlingpdf"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<AllToolsNavButtonProps> = ({
|
||||
activeButton,
|
||||
setActiveButton,
|
||||
tooltipPosition = 'right'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("quickAccess.allTools", "Tools")}
|
||||
position={tooltipPosition}
|
||||
arrow
|
||||
containerStyle={{ marginTop: "-1rem" }}
|
||||
maxWidth={200}
|
||||
>
|
||||
<div className="mt-4 mb-2">
|
||||
<QuickAccessButton
|
||||
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
||||
label={t("quickAccess.allTools", "Tools")}
|
||||
isActive={isActive}
|
||||
onClick={handleNavClick}
|
||||
href={navProps.href}
|
||||
ariaLabel={t("quickAccess.allTools", "Tools")}
|
||||
textClassName="all-tools-text"
|
||||
component="a"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="mt-4 mb-2">
|
||||
<QuickAccessButton
|
||||
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
||||
label={t("quickAccess.allTools", "Tools")}
|
||||
isActive={isActive}
|
||||
onClick={handleNavClick}
|
||||
href={navProps.href}
|
||||
ariaLabel={t("quickAccess.allTools", "Tools")}
|
||||
textClassName="all-tools-text"
|
||||
component="a"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
@ -28,6 +28,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
const [pendingAnnotationAfterRedaction, setPendingAnnotationAfterRedaction] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
@ -47,9 +48,19 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
const isRedactMode = selectedTool === 'redact';
|
||||
|
||||
// Get redaction pending state and navigation guard
|
||||
const { pendingCount: redactionPendingCount, isRedacting: _isRedacting } = useRedactionMode();
|
||||
const { pendingCount: redactionPendingCount, isRedacting: _isRedacting, activeType } = useRedactionMode();
|
||||
const { requestNavigation, setHasUnsavedChanges } = useNavigationGuard();
|
||||
const { setRedactionMode, activateTextSelection, setRedactionConfig, setRedactionsApplied } = useRedaction();
|
||||
const { setRedactionMode, activateTextSelection, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction();
|
||||
|
||||
const activateDrawingTools = useCallback(() => {
|
||||
if (!signatureApiRef?.current) return;
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}, [selectedColor, signatureApiRef]);
|
||||
|
||||
// Turn off annotation mode when switching away from viewer
|
||||
useEffect(() => {
|
||||
@ -60,15 +71,10 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
|
||||
// Activate draw mode when annotation mode becomes active
|
||||
useEffect(() => {
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
if (viewerContext?.isAnnotationMode && currentView === 'viewer') {
|
||||
activateDrawingTools();
|
||||
}
|
||||
}, [viewerContext?.isAnnotationMode, currentView, selectedColor, signatureApiRef]);
|
||||
}, [viewerContext?.isAnnotationMode, currentView, activateDrawingTools]);
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
@ -99,6 +105,13 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
}
|
||||
};
|
||||
|
||||
const exitRedactionMode = useCallback(() => {
|
||||
navActions.setToolAndWorkbench(null, 'viewer');
|
||||
setLeftPanelView('toolPicker');
|
||||
setRedactionMode(false);
|
||||
setActiveType(null);
|
||||
}, [navActions, setLeftPanelView, setRedactionMode, setActiveType]);
|
||||
|
||||
// Handle redaction mode toggle
|
||||
const handleRedactionToggle = async () => {
|
||||
if (isRedactMode) {
|
||||
@ -121,13 +134,22 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
}, 100);
|
||||
} else {
|
||||
// Exit redaction mode - keep viewer workbench and show all tools in sidebar
|
||||
navActions.setToolAndWorkbench(null, 'viewer');
|
||||
setLeftPanelView('toolPicker');
|
||||
setRedactionMode(false);
|
||||
exitRedactionMode();
|
||||
}
|
||||
} else {
|
||||
await saveAnnotationsIfNeeded();
|
||||
|
||||
if (viewerContext?.isAnnotationMode) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Unable to deactivate annotation tools:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enter redaction mode - select redact tool with manual mode
|
||||
// If we're already in the viewer, keep the viewer workbench and open the tool sidebar
|
||||
if (workbench === 'viewer') {
|
||||
@ -150,11 +172,32 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
setRedactionMode(true);
|
||||
// Activate text selection mode after a short delay
|
||||
setTimeout(() => {
|
||||
activateTextSelection();
|
||||
const currentType = redactionApiRef.current?.getActiveType?.();
|
||||
if (currentType !== 'redactSelection') {
|
||||
activateTextSelection();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
|
||||
const startAnnotationMode = useCallback(() => {
|
||||
viewerContext?.setAnnotationMode(true);
|
||||
activateDrawingTools();
|
||||
}, [viewerContext, activateDrawingTools]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (!isRedactMode && pendingAnnotationAfterRedaction) {
|
||||
timer = setTimeout(() => {
|
||||
setPendingAnnotationAfterRedaction(false);
|
||||
startAnnotationMode();
|
||||
}, 200);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [isRedactMode, pendingAnnotationAfterRedaction, startAnnotationMode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Redaction Mode Toggle */}
|
||||
@ -217,7 +260,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
viewerContext?.setAnnotationMode(false);
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
@ -228,7 +271,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
disabled={disabled}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
@ -259,14 +302,24 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
const scheduleAnnotationAfterRedaction = () => {
|
||||
setPendingAnnotationAfterRedaction(true);
|
||||
exitRedactionMode();
|
||||
};
|
||||
|
||||
const beginAnnotation = () => {
|
||||
if (isRedactMode) {
|
||||
scheduleAnnotationAfterRedaction();
|
||||
} else {
|
||||
startAnnotationMode();
|
||||
}
|
||||
};
|
||||
|
||||
// If in redaction mode with pending redactions, show warning modal
|
||||
if (isRedactMode && redactionPendingCount > 0) {
|
||||
requestNavigation(() => {
|
||||
viewerContext?.setAnnotationMode(true);
|
||||
});
|
||||
requestNavigation(beginAnnotation);
|
||||
} else {
|
||||
// Direct activation - useEffect will handle draw mode activation
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
beginAnnotation();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
|
||||
@ -53,6 +53,7 @@ const EmbedPdfViewerContent = ({
|
||||
getScrollState,
|
||||
getRotationState,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
isAnnotationsVisible,
|
||||
exportActions,
|
||||
} = useViewer();
|
||||
@ -90,11 +91,9 @@ const EmbedPdfViewerContent = ({
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
|
||||
// Check if we're in signature mode OR viewer annotation mode OR redaction mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
// Tools that use the stamp/signature placement system with hover preview
|
||||
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
// Check if we're in manual redaction mode
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
const isSignatureMode = isInAnnotationTool;
|
||||
const isManualRedactMode = selectedTool === 'redact';
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
@ -104,8 +103,15 @@ const EmbedPdfViewerContent = ({
|
||||
// Enable redaction when the redact tool is selected and annotation mode is NOT active
|
||||
// This allows switching between redaction and annotation tools while redact is the selected tool
|
||||
const shouldEnableRedaction = (isManualRedactMode || isRedactionMode) && !isAnnotationMode;
|
||||
|
||||
// Keep annotation mode enabled when entering placement tools without overriding manual toggles
|
||||
useEffect(() => {
|
||||
if (isInAnnotationTool) {
|
||||
setAnnotationMode(true);
|
||||
}
|
||||
}, [isInAnnotationTool, setAnnotationMode]);
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
isInAnnotationTool && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
@ -378,6 +384,7 @@ const EmbedPdfViewerContent = ({
|
||||
file={effectiveFile.file}
|
||||
url={effectiveFile.url}
|
||||
enableAnnotations={shouldEnableAnnotations}
|
||||
showBakedAnnotations={isAnnotationsVisible}
|
||||
enableRedaction={shouldEnableRedaction}
|
||||
isManualRedactionMode={isManualRedactMode}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
|
||||
@ -56,13 +56,25 @@ interface LocalEmbedPDFProps {
|
||||
enableAnnotations?: boolean;
|
||||
enableRedaction?: boolean;
|
||||
isManualRedactionMode?: boolean;
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, onSignatureAdded, signatureApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) {
|
||||
export function LocalEmbedPDF({
|
||||
file,
|
||||
url,
|
||||
enableAnnotations = false,
|
||||
enableRedaction = false,
|
||||
isManualRedactionMode = false,
|
||||
showBakedAnnotations = true,
|
||||
onSignatureAdded,
|
||||
signatureApiRef,
|
||||
historyApiRef,
|
||||
redactionTrackerRef,
|
||||
}: LocalEmbedPDFProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
@ -105,7 +117,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda
|
||||
}),
|
||||
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)
|
||||
@ -176,7 +188,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda
|
||||
// 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({
|
||||
|
||||
@ -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({
|
||||
</Button>
|
||||
|
||||
{/* Dual Page Toggle */}
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={
|
||||
<Tooltip
|
||||
content={
|
||||
isDualPageActive
|
||||
? t("viewer.singlePageView", "Single Page View")
|
||||
: t("viewer.dualPageView", "Dual Page View")
|
||||
}
|
||||
position="top"
|
||||
arrow
|
||||
>
|
||||
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
>
|
||||
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||
|
||||
@ -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<ViewerProviderProps> = ({ 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<ViewerProviderProps> = ({ children }) => {
|
||||
toggleAnnotationsVisibility,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
toggleAnnotationMode,
|
||||
|
||||
// Active file index
|
||||
activeFileIndex,
|
||||
|
||||
@ -14,6 +14,7 @@ interface SaaSLoginScreenProps {
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onSelfHostedClick: () => void;
|
||||
onSwitchToSignup: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@ -23,6 +24,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
onSelfHostedClick,
|
||||
onSwitchToSignup,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
@ -89,6 +91,20 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
/>
|
||||
|
||||
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
onSwitchToSignup();
|
||||
}}
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t('signup.signUp', 'Sign Up')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
||||
</>
|
||||
);
|
||||
|
||||
104
frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx
Normal file
104
frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx
Normal file
@ -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<void>;
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
|
||||
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<string | null>(null);
|
||||
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>({});
|
||||
const [signupSuccessMessage, setSignupSuccessMessage] = useState<string | null>(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 (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('signup.title', 'Create an account')}
|
||||
subtitle={t('signup.subtitle', 'Join Stirling PDF')}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
{signupSuccessMessage && (
|
||||
<div className="success-message">
|
||||
<p className="success-message-text">{signupSuccessMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SignupForm
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
setEmail={(value) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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<SetupWizardProps> = ({ 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<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribePromise = listen<string>('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<SetupWizardProps> = ({ 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<SetupWizardProps> = ({ onComplete }) => {
|
||||
onLogin={handleSaaSLogin}
|
||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||
onSelfHostedClick={handleSelfHostedClick}
|
||||
onSwitchToSignup={handleSwitchToSignup}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SaaSSignup && (
|
||||
<SaaSSignupScreen
|
||||
loading={loading}
|
||||
error={error}
|
||||
onLogin={handleSaaSLogin}
|
||||
onSwitchToLogin={handleSwitchToLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelectionScreen
|
||||
onSelect={handleServerSelection}
|
||||
|
||||
@ -6,6 +6,12 @@
|
||||
// The SaaS authentication server
|
||||
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
|
||||
|
||||
// SaaS signup URL for creating new cloud accounts
|
||||
export const STIRLING_SAAS_SIGNUP_URL: string = import.meta.env.VITE_SAAS_SIGNUP_URL || '';
|
||||
|
||||
// Supabase publishable key from environment variable
|
||||
// Used for SaaS authentication
|
||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
||||
|
||||
// Desktop deep link callback for Supabase email confirmations
|
||||
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import axios from 'axios';
|
||||
import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
|
||||
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
|
||||
|
||||
export interface UserInfo {
|
||||
username: string;
|
||||
@ -131,6 +131,67 @@ export class AuthService {
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
|
||||
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<void> {
|
||||
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<UserInfo> {
|
||||
try {
|
||||
console.log('Logging in to:', serverUrl);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user