From d714a1617fe2d3ff2e2c16a80370cd5a99c240dd Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 7 Oct 2025 11:48:42 +0100 Subject: [PATCH 1/4] V2 File Editor Shortcuts (#4619) # Description of Changes Add shortcut icons for Pin and Download, and rename Delete to Close for consistency with the rest of the tool. --- .../public/locales/en-GB/translation.json | 2 + .../fileEditor/FileEditor.module.css | 24 ++++- .../src/components/fileEditor/FileEditor.tsx | 4 +- .../fileEditor/FileEditorThumbnail.tsx | 91 +++++++++++++------ 4 files changed, 86 insertions(+), 35 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 76a498d2d..01a8278f6 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -66,6 +66,8 @@ "save": "Save", "saveToBrowser": "Save to Browser", "download": "Download", + "pin": "Pin", + "unpin": "Unpin", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", "moreOptions": "More Options", diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/components/fileEditor/FileEditor.module.css index 173738c29..17184bbf4 100644 --- a/frontend/src/components/fileEditor/FileEditor.module.css +++ b/frontend/src/components/fileEditor/FileEditor.module.css @@ -34,9 +34,9 @@ .header { height: 2.25rem; border-radius: 0.0625rem 0.0625rem 0 0; - display: grid; - grid-template-columns: 44px 1fr 44px; + display: flex; align-items: center; + justify-content: space-between; padding: 0 6px; user-select: none; background: var(--bg-toolbar); @@ -86,14 +86,23 @@ } .headerIndex { + position: absolute; + left: 50%; + transform: translateX(-50%); text-align: center; font-weight: 500; font-size: 18px; letter-spacing: 0.04em; } -.kebab { - justify-self: end; +.headerActions { + display: flex; + align-items: center; + gap: 8px; + margin-left: auto; +} + +.headerIconButton { color: #FFFFFF !important; } @@ -216,6 +225,11 @@ color: rgba(0, 0, 0, 0.35); } +.pinned { + color: #FFC107 !important; +} + + /* Unsupported file indicator */ .unsupportedPill { margin-left: 1.75rem; @@ -384,4 +398,4 @@ .addFileSubtext { font-size: 0.875rem; opacity: 0.8; -} \ No newline at end of file +} diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 626eaab4f..90ee6eebe 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -288,7 +288,7 @@ const FileEditor = ({ // File operations using context - const handleDeleteFile = useCallback((fileId: FileId) => { + const handleCloseFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { @@ -467,7 +467,7 @@ const FileEditor = ({ selectedFiles={localSelectedIds} selectionMode={selectionMode} onToggleFile={toggleFile} - onDeleteFile={handleDeleteFile} + onCloseFile={handleCloseFile} onViewFile={handleViewFile} _onSetStatus={showStatus} onReorderFiles={handleReorderFiles} diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index f09bfeeb1..8159e30a9 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -1,10 +1,10 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; +import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core'; import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; -import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CloseIcon from '@mui/icons-material/Close'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; @@ -29,7 +29,7 @@ interface FileEditorThumbnailProps { selectedFiles: FileId[]; selectionMode: boolean; onToggleFile: (fileId: FileId) => void; - onDeleteFile: (fileId: FileId) => void; + onCloseFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; _onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; @@ -44,7 +44,7 @@ const FileEditorThumbnail = ({ index, selectedFiles, onToggleFile, - onDeleteFile, + onCloseFile, _onSetStatus, onReorderFiles, onDownloadFile, @@ -258,18 +258,60 @@ const FileEditorThumbnail = ({ {index + 1} - {/* Kebab menu */} - { - e.stopPropagation(); - setShowActions((v) => !v); - }} - > - - + {/* Action buttons group */} +
+ {/* Pin/Unpin icon */} + + { + e.stopPropagation(); + if (actualFile) { + if (isPinned) { + unpinFile(actualFile); + alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); + } else { + pinFile(actualFile); + alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); + } + } + }} + > + {isPinned ? : } + + + + {/* Download icon */} + + { + e.stopPropagation(); + onDownloadFile(file.id); + alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); + }} + > + + + + + {/* Kebab menu */} + { + e.stopPropagation(); + setShowActions((v) => !v); + }} + > + + +
{/* Actions overlay */} @@ -294,7 +336,7 @@ const FileEditorThumbnail = ({ setShowActions(false); }} > - {isPinned ? : } + {isPinned ? : } {isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')} @@ -321,13 +363,13 @@ const FileEditorThumbnail = ({ )} @@ -394,13 +436,6 @@ const FileEditorThumbnail = ({ )} - {/* Pin indicator (bottom-left) */} - {isPinned && ( - - - - )} - {/* Drag handle (span wrapper so we can attach a ref reliably) */} From 3090a85726d7cf915656942c806e6e2e386c4ba6 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 8 Oct 2025 17:18:05 +0100 Subject: [PATCH 2/4] Assign shortcuts by default to only the quick access items (#4622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Change shortcuts to just be a limited set for Quick Access tools rather than for everything to avoid breaking browser key commands by default. Also fixes a bunch of types of variables that were representing `ToolId`s (I stopped at `automate` because there's loads in there so I've just introduced some `any` casts for now 😭) --- .../public/locales/en-GB/translation.json | 3 +- .../src/components/hotkeys/HotkeyDisplay.tsx | 2 +- .../config/configSections/HotkeysSection.tsx | 14 ++- .../src/components/tools/SearchResults.tsx | 9 +- frontend/src/components/tools/ToolPicker.tsx | 3 +- .../src/components/tools/ToolRenderer.tsx | 3 +- .../tools/automate/ToolSelector.tsx | 7 +- .../tools/shared/renderToolButtons.tsx | 5 +- .../tools/toolPicker/ToolButton.tsx | 21 ++-- frontend/src/contexts/HotkeyContext.tsx | 104 +++++++++--------- frontend/src/contexts/ToolWorkflowContext.tsx | 8 +- .../src/data/useTranslatedToolRegistry.tsx | 2 +- frontend/src/hooks/useSidebarNavigation.ts | 9 +- frontend/src/hooks/useToolManagement.tsx | 15 +-- frontend/src/hooks/useToolSections.ts | 15 +-- frontend/src/utils/toolSearch.ts | 19 ++-- 16 files changed, 125 insertions(+), 114 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 01a8278f6..1be334754 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -283,7 +283,8 @@ "capturing": "Press keys… (Esc to cancel)", "change": "Change shortcut", "reset": "Reset", - "shortcut": "Shortcut" + "shortcut": "Shortcut", + "noShortcut": "No shortcut set" } }, "changeCreds": { diff --git a/frontend/src/components/hotkeys/HotkeyDisplay.tsx b/frontend/src/components/hotkeys/HotkeyDisplay.tsx index e240f987e..1b5953a63 100644 --- a/frontend/src/components/hotkeys/HotkeyDisplay.tsx +++ b/frontend/src/components/hotkeys/HotkeyDisplay.tsx @@ -55,4 +55,4 @@ export const HotkeyDisplay: React.FC = ({ binding, size = 's ); }; -export default HotkeyDisplay; \ No newline at end of file +export default HotkeyDisplay; diff --git a/frontend/src/components/shared/config/configSections/HotkeysSection.tsx b/frontend/src/components/shared/config/configSections/HotkeysSection.tsx index e0240f55a..9630d6f05 100644 --- a/frontend/src/components/shared/config/configSections/HotkeysSection.tsx +++ b/frontend/src/components/shared/config/configSections/HotkeysSection.tsx @@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next'; import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext'; import { useHotkeys } from '../../../../contexts/HotkeyContext'; import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay'; -import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys'; +import { bindingEquals, eventToBinding, HotkeyBinding } from '../../../../utils/hotkeys'; +import { ToolId } from 'src/types/toolId'; +import { ToolRegistryEntry } from 'src/data/toolsTaxonomy'; const rowStyle: React.CSSProperties = { display: 'flex', @@ -24,10 +26,10 @@ const HotkeysSection: React.FC = () => { const { t } = useTranslation(); const { toolRegistry } = useToolWorkflow(); const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys(); - const [editingTool, setEditingTool] = useState(null); + const [editingTool, setEditingTool] = useState(null); const [error, setError] = useState(null); - const tools = useMemo(() => Object.entries(toolRegistry), [toolRegistry]); + const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]); useEffect(() => { if (!editingTool) { @@ -64,7 +66,7 @@ const HotkeysSection: React.FC = () => { return; } - const conflictEntry = Object.entries(hotkeys).find(([toolId, existing]) => ( + const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find(([toolId, existing]) => ( toolId !== editingTool && bindingEquals(existing, binding) )); @@ -85,7 +87,7 @@ const HotkeysSection: React.FC = () => { }; }, [editingTool, hotkeys, toolRegistry, updateHotkey, t]); - const handleStartCapture = (toolId: string) => { + const handleStartCapture = (toolId: ToolId) => { setEditingTool(toolId); setError(null); }; @@ -168,4 +170,4 @@ const HotkeysSection: React.FC = () => { ); }; -export default HotkeysSection; \ No newline at end of file +export default HotkeysSection; diff --git a/frontend/src/components/tools/SearchResults.tsx b/frontend/src/components/tools/SearchResults.tsx index 5bd6036cc..aca9f88bc 100644 --- a/frontend/src/components/tools/SearchResults.tsx +++ b/frontend/src/components/tools/SearchResults.tsx @@ -7,9 +7,10 @@ import { useToolSections } from '../../hooks/useToolSections'; import SubcategoryHeader from './shared/SubcategoryHeader'; import NoToolsFound from './shared/NoToolsFound'; import "./toolPicker/ToolPicker.css"; +import { ToolId } from 'src/types/toolId'; interface SearchResultsProps { - filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; + filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; onSelect: (id: string) => void; searchQuery?: string; } @@ -40,13 +41,13 @@ const SearchResults: React.FC = ({ filteredTools, onSelect, {group.tools.map(({ id, tool }) => { const matchedText = matchedTextMap.get(id); // Check if the match was from synonyms and show the actual synonym that matched - const isSynonymMatch = matchedText && tool.synonyms?.some(synonym => + const isSynonymMatch = matchedText && tool.synonyms?.some(synonym => matchedText.toLowerCase().includes(synonym.toLowerCase()) ); - const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym => + const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym => matchedText.toLowerCase().includes(synonym.toLowerCase()) ) : undefined; - + return ( void; - filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; + filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; isSearching?: boolean; } diff --git a/frontend/src/components/tools/ToolRenderer.tsx b/frontend/src/components/tools/ToolRenderer.tsx index fbd8100d1..97353ba09 100644 --- a/frontend/src/components/tools/ToolRenderer.tsx +++ b/frontend/src/components/tools/ToolRenderer.tsx @@ -2,9 +2,10 @@ import { Suspense } from "react"; import { useToolWorkflow } from "../../contexts/ToolWorkflowContext"; import { BaseToolProps } from "../../types/tool"; import ToolLoadingFallback from "./ToolLoadingFallback"; +import { ToolId } from "src/types/toolId"; interface ToolRendererProps extends BaseToolProps { - selectedToolKey: string; + selectedToolKey: ToolId; } diff --git a/frontend/src/components/tools/automate/ToolSelector.tsx b/frontend/src/components/tools/automate/ToolSelector.tsx index 5cbc4b3ab..02fea2c97 100644 --- a/frontend/src/components/tools/automate/ToolSelector.tsx +++ b/frontend/src/components/tools/automate/ToolSelector.tsx @@ -6,6 +6,7 @@ import { useToolSections } from '../../../hooks/useToolSections'; import { renderToolButtons } from '../shared/renderToolButtons'; import ToolSearch from '../toolPicker/ToolSearch'; import ToolButton from '../toolPicker/ToolButton'; +import { ToolId } from 'src/types/toolId'; interface ToolSelectorProps { onSelect: (toolKey: string) => void; @@ -30,7 +31,7 @@ export default function ToolSelector({ // Filter out excluded tools (like 'automate' itself) and tools that don't support automation const baseFilteredTools = useMemo(() => { - return Object.entries(toolRegistry).filter(([key, tool]) => + return (Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]).filter(([key, tool]) => !excludeTools.includes(key) && getToolSupportsAutomate(tool) ); }, [toolRegistry, excludeTools]); @@ -66,7 +67,7 @@ export default function ToolSelector({ }, [filteredTools]); // Use the same tool sections logic as the main ToolPicker - const { sections, searchGroups } = useToolSections(transformedFilteredTools); + const { sections, searchGroups } = useToolSections(transformedFilteredTools as any /* FIX ME */); // Determine what to display: search results or organized sections const isSearching = searchTerm.trim().length > 0; @@ -156,7 +157,7 @@ export default function ToolSelector({ // Show selected tool in AutomationEntry style when tool is selected and dropdown closed
- {}} rounded={true} disableNavigation={true}>
) : ( diff --git a/frontend/src/components/tools/shared/renderToolButtons.tsx b/frontend/src/components/tools/shared/renderToolButtons.tsx index a4aadf20b..659ffefca 100644 --- a/frontend/src/components/tools/shared/renderToolButtons.tsx +++ b/frontend/src/components/tools/shared/renderToolButtons.tsx @@ -5,13 +5,14 @@ import SubcategoryHeader from './SubcategoryHeader'; import { getSubcategoryLabel } from "../../../data/toolsTaxonomy"; import { TFunction } from 'i18next'; import { SubcategoryGroup } from '../../../hooks/useToolSections'; +import { ToolId } from 'src/types/toolId'; // Helper function to render tool buttons for a subcategory export const renderToolButtons = ( t: TFunction, subcategory: SubcategoryGroup, selectedToolKey: string | null, - onSelect: (id: string) => void, + onSelect: (id: ToolId) => void, showSubcategoryHeader: boolean = true, disableNavigation: boolean = false, searchResults?: Array<{ item: [string, any]; matchedText?: string }> @@ -32,7 +33,7 @@ export const renderToolButtons = (
{subcategory.tools.map(({ id, tool }) => { const matchedSynonym = matchedTextMap.get(id); - + return ( void; + onSelect: (id: ToolId) => void; rounded?: boolean; disableNavigation?: boolean; matchedSynonym?: string; @@ -28,7 +29,7 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, const binding = hotkeys[id]; const { getToolNavigation } = useToolNavigation(); - const handleClick = (id: string) => { + const handleClick = (id: ToolId) => { if (isUnavailable) return; if (tool.link) { // Open external link in new tab @@ -47,12 +48,16 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, : (
{tool.description} - {binding && ( -
+
+ {binding ? ( + <> {t('settings.hotkeys.shortcut', 'Shortcut')} -
+ + ) : ( + {t('settings.hotkeys.noShortcut', 'No shortcut set')} )} +
); @@ -102,7 +107,7 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ + styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, label: { overflow: 'visible' } }} @@ -123,7 +128,7 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ + styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, label: { overflow: 'visible' } }} diff --git a/frontend/src/contexts/HotkeyContext.tsx b/frontend/src/contexts/HotkeyContext.tsx index fe9cbb600..c5a0f2649 100644 --- a/frontend/src/contexts/HotkeyContext.tsx +++ b/frontend/src/contexts/HotkeyContext.tsx @@ -2,14 +2,17 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useS import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '../utils/hotkeys'; import { useToolWorkflow } from './ToolWorkflowContext'; import { ToolId } from '../types/toolId'; +import { ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy'; + +type Bindings = Partial>; interface HotkeyContextValue { - hotkeys: Record; - defaults: Record; + hotkeys: Bindings; + defaults: Bindings; isMac: boolean; - updateHotkey: (toolId: string, binding: HotkeyBinding) => void; - resetHotkey: (toolId: string) => void; - isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: string) => boolean; + updateHotkey: (toolId: ToolId, binding: HotkeyBinding) => void; + resetHotkey: (toolId: ToolId) => void; + isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: ToolId) => boolean; pauseHotkeys: () => void; resumeHotkeys: () => void; areHotkeysPaused: boolean; @@ -20,46 +23,29 @@ const HotkeyContext = createContext(undefined); const STORAGE_KEY = 'stirlingpdf.hotkeys'; -const KEY_ORDER: string[] = [ - 'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0', - 'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP', - 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL', - 'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM', - 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', -]; +const generateDefaultHotkeys = (toolEntries: [ToolId, ToolRegistryEntry][], macLike: boolean): Bindings => { + const defaults: Bindings = {}; -const generateDefaultHotkeys = (toolIds: string[], macLike: boolean): Record => { - const defaults: Record = {}; - let index = 0; - let useShift = false; + // Get Quick Access tools (RECOMMENDED_TOOLS category) from registry + const quickAccessTools = toolEntries + .filter(([_, tool]) => tool.categoryId === ToolCategoryId.RECOMMENDED_TOOLS) + .map(([toolId, _]) => toolId); - const nextBinding = (): HotkeyBinding => { - if (index >= KEY_ORDER.length) { - index = 0; - if (!useShift) { - useShift = true; - } else { - // If we somehow run out of combinations, wrap back around (unlikely given tool count) - useShift = false; - } + // Assign Cmd+Option+Number (Mac) or Ctrl+Alt+Number (Windows) to Quick Access tools + quickAccessTools.forEach((toolId, index) => { + if (index < 9) { // Limit to Digit1-9 + const digitNumber = index + 1; + defaults[toolId] = { + code: `Digit${digitNumber}`, + alt: true, + shift: false, + meta: macLike, + ctrl: !macLike, + }; } - - const code = KEY_ORDER[index]; - index += 1; - - return { - code, - alt: true, - shift: useShift, - meta: macLike, - ctrl: !macLike, - }; - }; - - toolIds.forEach(toolId => { - defaults[toolId] = nextBinding(); }); + // All other tools have no default (will be undefined in the record) return defaults; }; @@ -74,7 +60,7 @@ const shouldIgnoreTarget = (target: EventTarget | null): boolean => { export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { toolRegistry, handleToolSelect } = useToolWorkflow(); const isMac = useMemo(() => isMacLike(), []); - const [customBindings, setCustomBindings] = useState>(() => { + const [customBindings, setCustomBindings] = useState(() => { if (typeof window === 'undefined') { return {}; } @@ -82,16 +68,16 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr }); const [areHotkeysPaused, setHotkeysPaused] = useState(false); - const toolIds = useMemo(() => Object.keys(toolRegistry), [toolRegistry]); + const toolEntries = useMemo(() => Object.entries(toolRegistry), [toolRegistry]) as [ToolId, ToolRegistryEntry][]; - const defaults = useMemo(() => generateDefaultHotkeys(toolIds, isMac), [toolIds, isMac]); + const defaults = useMemo(() => generateDefaultHotkeys(toolEntries, isMac), [toolRegistry, isMac]); // Remove bindings for tools that are no longer present useEffect(() => { setCustomBindings(prev => { - const next: Record = {}; + const next: Bindings = {}; let changed = false; - Object.entries(prev).forEach(([toolId, binding]) => { + (Object.entries(prev) as [ToolId, HotkeyBinding][]).forEach(([toolId, binding]) => { if (toolRegistry[toolId]) { next[toolId] = binding; } else { @@ -103,13 +89,21 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr }, [toolRegistry]); const resolved = useMemo(() => { - const merged: Record = {}; - toolIds.forEach(toolId => { + const merged: Bindings = {}; + toolEntries.forEach(([toolId, _]) => { const custom = customBindings[toolId]; - merged[toolId] = custom ? normalizeBinding(custom) : defaults[toolId]; + const defaultBinding = defaults[toolId]; + + // Only add to resolved if there's a custom binding or a default binding + if (custom) { + merged[toolId] = normalizeBinding(custom); + } else if (defaultBinding) { + merged[toolId] = defaultBinding; + } + // If neither exists, don't add to merged (tool has no hotkey) }); return merged; - }, [customBindings, defaults, toolIds]); + }, [customBindings, defaults, toolEntries]); useEffect(() => { if (typeof window === 'undefined') { @@ -118,7 +112,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings)); }, [customBindings]); - const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: string) => { + const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: ToolId) => { const normalized = normalizeBinding(binding); return Object.entries(resolved).every(([toolId, existing]) => { if (toolId === excludeToolId) { @@ -128,7 +122,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr }); }, [resolved]); - const updateHotkey = useCallback((toolId: string, binding: HotkeyBinding) => { + const updateHotkey = useCallback((toolId: ToolId, binding: HotkeyBinding) => { setCustomBindings(prev => { const normalized = normalizeBinding(binding); const defaultsForTool = defaults[toolId]; @@ -142,7 +136,7 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr }); }, [defaults]); - const resetHotkey = useCallback((toolId: string) => { + const resetHotkey = useCallback((toolId: ToolId) => { setCustomBindings(prev => { if (!(toolId in prev)) { return prev; @@ -165,12 +159,12 @@ export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ childr if (event.repeat) return; if (shouldIgnoreTarget(event.target)) return; - const entries = Object.entries(resolved) as Array<[string, HotkeyBinding]>; + const entries = Object.entries(resolved) as [ToolId, HotkeyBinding][]; for (const [toolId, binding] of entries) { if (bindingMatchesEvent(binding, event)) { event.preventDefault(); event.stopPropagation(); - handleToolSelect(toolId as ToolId); + handleToolSelect(toolId); break; } } @@ -208,4 +202,4 @@ export const useHotkeys = (): HotkeyContextValue => { throw new Error('useHotkeys must be used within a HotkeyProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index 98a0fd37d..73ef058bf 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -73,10 +73,10 @@ function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowActio // Context value interface interface ToolWorkflowContextValue extends ToolWorkflowState { // Tool management (from hook) - selectedToolKey: string | null; + selectedToolKey: ToolId | null; selectedTool: ToolRegistryEntry | null; - toolRegistry: Record; - getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null; + toolRegistry: Partial; + getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null; // UI Actions setSidebarsVisible: (visible: boolean) => void; @@ -101,7 +101,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState { handleReaderToggle: () => void; // Computed values - filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search + filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; // Filtered by search isPanelVisible: boolean; } diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index 745738566..fc500aaee 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -824,7 +824,7 @@ export function useFlatToolRegistry(): ToolRegistry { name: t("home.compare.title", "Compare"), component: null, description: t("home.compare.desc", "Compare two PDF documents and highlight differences"), - categoryId: ToolCategoryId.RECOMMENDED_TOOLS, + categoryId: ToolCategoryId.STANDARD_TOOLS /* TODO: Change to RECOMMENDED_TOOLS when component is implemented */, subcategoryId: SubcategoryId.GENERAL, synonyms: getSynonyms(t, "compare"), supportsAutomate: false, diff --git a/frontend/src/hooks/useSidebarNavigation.ts b/frontend/src/hooks/useSidebarNavigation.ts index a65ea96de..42f92e0f1 100644 --- a/frontend/src/hooks/useSidebarNavigation.ts +++ b/frontend/src/hooks/useSidebarNavigation.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useToolNavigation } from './useToolNavigation'; import { useToolWorkflow } from '../contexts/ToolWorkflowContext'; import { handleUnlessSpecialClick } from '../utils/clickHandlers'; +import { ToolId } from 'src/types/toolId'; export interface SidebarNavigationProps { /** Full URL for the navigation (for href attribute) */ @@ -16,7 +17,7 @@ export interface SidebarNavigationProps { */ export function useSidebarNavigation(): { getHomeNavigation: () => SidebarNavigationProps; - getToolNavigation: (toolId: string) => SidebarNavigationProps | null; + getToolNavigation: (toolId: ToolId) => SidebarNavigationProps | null; } { const { getToolNavigation: getToolNavProps } = useToolNavigation(); const { getSelectedTool } = useToolWorkflow(); @@ -32,14 +33,14 @@ export function useSidebarNavigation(): { return { href, onClick: defaultNavClick }; }, [defaultNavClick]); - const getToolNavigation = useCallback((toolId: string): SidebarNavigationProps | null => { + const getToolNavigation = useCallback((toolId: ToolId): SidebarNavigationProps | null => { // Handle special nav sections that aren't tools if (toolId === 'read') return { href: '/read', onClick: defaultNavClick }; if (toolId === 'automate') return { href: '/automate', onClick: defaultNavClick }; const tool = getSelectedTool(toolId); if (!tool) return null; - + // Delegate to useToolNavigation for true tools return getToolNavProps(toolId, tool); }, [getToolNavProps, getSelectedTool, defaultNavClick]); @@ -48,4 +49,4 @@ export function useSidebarNavigation(): { getHomeNavigation, getToolNavigation }; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useToolManagement.tsx b/frontend/src/hooks/useToolManagement.tsx index 3239cbaaa..054e99304 100644 --- a/frontend/src/hooks/useToolManagement.tsx +++ b/frontend/src/hooks/useToolManagement.tsx @@ -1,16 +1,17 @@ import { useState, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; -import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy"; +import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "../data/toolsTaxonomy"; import { useMultipleEndpointsEnabled } from "./useEndpointConfig"; import { FileId } from '../types/file'; +import { ToolId } from 'src/types/toolId'; interface ToolManagementResult { selectedTool: ToolRegistryEntry | null; toolSelectedFileIds: FileId[]; - toolRegistry: Record; + toolRegistry: Partial; setToolSelectedFileIds: (fileIds: FileId[]) => void; - getSelectedTool: (toolKey: string | null) => ToolRegistryEntry | null; + getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null; } export const useToolManagement = (): ToolManagementResult => { @@ -30,9 +31,9 @@ export const useToolManagement = (): ToolManagementResult => { return endpoints.length === 0 || endpoints.some((endpoint: string) => endpointStatus[endpoint] === true); }, [endpointsLoading, endpointStatus, baseRegistry]); - const toolRegistry: Record = useMemo(() => { - const availableToolRegistry: Record = {}; - Object.keys(baseRegistry).forEach(toolKey => { + const toolRegistry: Partial = useMemo(() => { + const availableToolRegistry: Partial = {}; + (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { if (isToolAvailable(toolKey)) { const baseTool = baseRegistry[toolKey as keyof typeof baseRegistry]; availableToolRegistry[toolKey] = { @@ -45,7 +46,7 @@ export const useToolManagement = (): ToolManagementResult => { return availableToolRegistry; }, [isToolAvailable, t, baseRegistry]); - const getSelectedTool = useCallback((toolKey: string | null): ToolRegistryEntry | null => { + const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => { return toolKey ? toolRegistry[toolKey] || null : null; }, [toolRegistry]); diff --git a/frontend/src/hooks/useToolSections.ts b/frontend/src/hooks/useToolSections.ts index 5c5be8773..40eac31e6 100644 --- a/frontend/src/hooks/useToolSections.ts +++ b/frontend/src/hooks/useToolSections.ts @@ -2,9 +2,10 @@ import { useMemo } from 'react'; import { SUBCATEGORY_ORDER, SubcategoryId, ToolCategoryId, ToolRegistryEntry } from '../data/toolsTaxonomy'; import { useTranslation } from 'react-i18next'; +import { ToolId } from 'src/types/toolId'; type SubcategoryIdMap = { - [subcategoryId in SubcategoryId]: Array<{ id: string /* FIX ME: Should be ToolId */; tool: ToolRegistryEntry }>; + [subcategoryId in SubcategoryId]: Array<{ id: ToolId; tool: ToolRegistryEntry }>; } type GroupedTools = { @@ -14,7 +15,7 @@ type GroupedTools = { export interface SubcategoryGroup { subcategoryId: SubcategoryId; tools: { - id: string /* FIX ME: Should be ToolId */; + id: ToolId; tool: ToolRegistryEntry; }[]; }; @@ -28,7 +29,7 @@ export interface ToolSection { }; export function useToolSections( - filteredTools: Array<{ item: [string /* FIX ME: Should be ToolId */, ToolRegistryEntry]; matchedText?: string }>, + filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>, searchQuery?: string ) { const { t } = useTranslation(); @@ -37,7 +38,7 @@ export function useToolSections( if (!filteredTools || !Array.isArray(filteredTools)) { return {} as GroupedTools; } - + const grouped = {} as GroupedTools; filteredTools.forEach(({ item: [id, tool] }) => { const categoryId = tool.categoryId; @@ -105,11 +106,11 @@ export function useToolSections( if (!filteredTools || !Array.isArray(filteredTools)) { return []; } - + const subMap = {} as SubcategoryIdMap; - const seen = new Set(); + const seen = new Set(); filteredTools.forEach(({ item: [id, tool] }) => { - const toolId = id as string /* FIX ME: Should be ToolId */; + const toolId = id as ToolId; if (seen.has(toolId)) return; seen.add(toolId); const sub = tool.subcategoryId; diff --git a/frontend/src/utils/toolSearch.ts b/frontend/src/utils/toolSearch.ts index dda5749a8..8bd963564 100644 --- a/frontend/src/utils/toolSearch.ts +++ b/frontend/src/utils/toolSearch.ts @@ -1,8 +1,9 @@ +import { ToolId } from "src/types/toolId"; import { ToolRegistryEntry } from "../data/toolsTaxonomy"; import { scoreMatch, minScoreForQuery, normalizeForSearch } from "./fuzzySearch"; export interface RankedToolItem { - item: [string, ToolRegistryEntry]; + item: [ToolId, ToolRegistryEntry]; matchedText?: string; } @@ -10,18 +11,18 @@ export function filterToolRegistryByQuery( toolRegistry: Record, query: string ): RankedToolItem[] { - const entries = Object.entries(toolRegistry); + const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]; if (!query.trim()) { - return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] })); + return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] })); } const nq = normalizeForSearch(query); const threshold = minScoreForQuery(query); - const exactName: Array<{ id: string; tool: ToolRegistryEntry; pos: number }> = []; - const exactSyn: Array<{ id: string; tool: ToolRegistryEntry; text: string; pos: number }> = []; - const fuzzyName: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = []; - const fuzzySyn: Array<{ id: string; tool: ToolRegistryEntry; score: number; text: string }> = []; + const exactName: Array<{ id: ToolId; tool: ToolRegistryEntry; pos: number }> = []; + const exactSyn: Array<{ id: ToolId; tool: ToolRegistryEntry; text: string; pos: number }> = []; + const fuzzyName: Array<{ id: ToolId; tool: ToolRegistryEntry; score: number; text: string }> = []; + const fuzzySyn: Array<{ id: ToolId; tool: ToolRegistryEntry; score: number; text: string }> = []; for (const [id, tool] of entries) { const nameNorm = normalizeForSearch(tool.name || ''); @@ -78,7 +79,7 @@ export function filterToolRegistryByQuery( const seen = new Set(); const ordered: RankedToolItem[] = []; - const push = (id: string, tool: ToolRegistryEntry, matchedText?: string) => { + const push = (id: ToolId, tool: ToolRegistryEntry, matchedText?: string) => { if (seen.has(id)) return; seen.add(id); ordered.push({ item: [id, tool], matchedText }); @@ -92,7 +93,7 @@ export function filterToolRegistryByQuery( if (ordered.length > 0) return ordered; // Fallback: return everything unchanged - return entries.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] })); + return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry] })); } From 2158ee4db6120f1cd28fcb20841350c0b124ac67 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:22:17 +0100 Subject: [PATCH 3/4] Feature/v2/googleDrive (#4592) Google drive oss. Shouldn't have any effect on pr deployment. Mainly the removal of the old integration via backend. I have added the picker service and lazy loading of the required google dependency scripts when the necessary environment variables have been implemented. --------- Co-authored-by: Connor Yoh Co-authored-by: James Brunton --- .../common/configuration/AppConfig.java | 6 - .../common/model/ApplicationProperties.java | 21 -- .../model/ApplicationPropertiesLogicTest.java | 16 - .../controller/api/misc/ConfigController.java | 5 - .../src/main/resources/settings.yml.template | 5 - .../resources/templates/fragments/common.html | 16 - .../configuration/ee/EEAppConfig.java | 14 - docker/README.md | 9 +- docker/compose/docker-compose.fat.yml | 3 + docker/compose/docker-compose.ultra-lite.yml | 3 + docker/compose/docker-compose.yml | 3 + frontend/package-lock.json | 74 +++++ frontend/package.json | 4 + frontend/src/components/FileManager.tsx | 25 ++ .../fileManager/FileSourceButtons.tsx | 34 +- .../shared/config/configSections/Overview.tsx | 1 - frontend/src/contexts/FileManagerContext.tsx | 16 + frontend/src/hooks/useAppConfig.ts | 1 - frontend/src/hooks/useGoogleDrivePicker.ts | 98 ++++++ .../src/services/googleDrivePickerService.ts | 295 ++++++++++++++++++ frontend/src/utils/scriptLoader.ts | 55 ++++ 21 files changed, 612 insertions(+), 92 deletions(-) create mode 100644 frontend/src/hooks/useGoogleDrivePicker.ts create mode 100644 frontend/src/services/googleDrivePickerService.ts create mode 100644 frontend/src/utils/scriptLoader.ts diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index b8fa08739..3bcc48715 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -258,12 +258,6 @@ public class AppConfig { return false; } - @Bean(name = "GoogleDriveEnabled") - @Profile("default") - public boolean googleDriveEnabled() { - return false; - } - @Bean(name = "license") @Profile("default") public String licenseType() { diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 7eeace787..14704d825 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -530,7 +530,6 @@ public class ApplicationProperties { private boolean ssoAutoLogin; private boolean database; private CustomMetadata customMetadata = new CustomMetadata(); - private GoogleDrive googleDrive = new GoogleDrive(); @Data public static class CustomMetadata { @@ -549,26 +548,6 @@ public class ApplicationProperties { : producer; } } - - @Data - public static class GoogleDrive { - private boolean enabled; - private String clientId; - private String apiKey; - private String appId; - - public String getClientId() { - return clientId == null || clientId.trim().isEmpty() ? "" : clientId; - } - - public String getApiKey() { - return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey; - } - - public String getAppId() { - return appId == null || appId.trim().isEmpty() ? "" : appId; - } - } } @Data diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java index da83fd462..66078099f 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -109,22 +109,6 @@ class ApplicationPropertiesLogicTest { assertTrue(ex.getMessage().toLowerCase().contains("not supported")); } - @Test - void premium_google_drive_getters_return_empty_string_on_null_or_blank() { - Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive(); - - assertEquals("", gd.getClientId()); - assertEquals("", gd.getApiKey()); - assertEquals("", gd.getAppId()); - - gd.setClientId(" id "); - gd.setApiKey(" key "); - gd.setAppId(" app "); - assertEquals(" id ", gd.getClientId()); - assertEquals(" key ", gd.getApiKey()); - assertEquals(" app ", gd.getAppId()); - } - @Test void ui_getters_return_null_for_blank() { ApplicationProperties.Ui ui = new ApplicationProperties.Ui(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 6d9263270..072471e5c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -98,11 +98,6 @@ public class ConfigController { if (applicationContext.containsBean("license")) { configData.put("license", applicationContext.getBean("license", String.class)); } - if (applicationContext.containsBean("GoogleDriveEnabled")) { - configData.put( - "GoogleDriveEnabled", - applicationContext.getBean("GoogleDriveEnabled", Boolean.class)); - } if (applicationContext.containsBean("SSOAutoLogin")) { configData.put( "SSOAutoLogin", diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 465f95fb6..8143ba4c2 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -76,11 +76,6 @@ premium: author: username creator: Stirling-PDF producer: Stirling-PDF - googleDrive: - enabled: false - clientId: '' - apiKey: '' - appId: '' enterpriseFeatures: audit: enabled: true # Enable audit logging diff --git a/app/core/src/main/resources/templates/fragments/common.html b/app/core/src/main/resources/templates/fragments/common.html index d3b888a1d..973822b58 100644 --- a/app/core/src/main/resources/templates/fragments/common.html +++ b/app/core/src/main/resources/templates/fragments/common.html @@ -422,10 +422,6 @@
-
- -
- google drive
@@ -443,16 +439,4 @@ - -
- - - - - -
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java index 215b82347..2e71b670d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java @@ -12,7 +12,6 @@ import org.springframework.core.annotation.Order; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.EnterpriseEdition; import stirling.software.common.model.ApplicationProperties.Premium; -import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive; @Configuration @Order(Ordered.HIGHEST_PRECEDENCE) @@ -55,19 +54,6 @@ public class EEAppConfig { return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin(); } - @Profile("security") - @Bean(name = "GoogleDriveEnabled") - @Primary - public boolean googleDriveEnabled() { - return runningProOrHigher() - && applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled(); - } - - @Bean(name = "GoogleDriveConfig") - public GoogleDrive googleDriveConfig() { - return applicationProperties.getPremium().getProFeatures().getGoogleDrive(); - } - // TODO: Remove post migration @SuppressWarnings("deprecation") public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) { diff --git a/docker/README.md b/docker/README.md index df07e6b9e..99b86b53e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -50,7 +50,14 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build - **Custom Ports**: Modify port mappings in docker-compose files - **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat) +### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview) + +- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create) +- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis) +- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings + ## Development vs Production - **Development**: Keep backend port 8080 exposed for debugging -- **Production**: Remove backend port exposure, use only frontend proxy \ No newline at end of file +- **Production**: Remove backend port exposure, use only frontend proxy + diff --git a/docker/compose/docker-compose.fat.yml b/docker/compose/docker-compose.fat.yml index 1757782d5..d0242eb3c 100644 --- a/docker/compose/docker-compose.fat.yml +++ b/docker/compose/docker-compose.fat.yml @@ -47,6 +47,9 @@ services: - "3000:80" environment: BACKEND_URL: http://backend:8080 + #VITE_GOOGLE_DRIVE_CLIENT_ID: + #VITE_GOOGLE_DRIVE_API_KEY: + #VITE_GOOGLE_DRIVE_APP_ID: depends_on: - backend networks: diff --git a/docker/compose/docker-compose.ultra-lite.yml b/docker/compose/docker-compose.ultra-lite.yml index bfbf55861..0639b53ac 100644 --- a/docker/compose/docker-compose.ultra-lite.yml +++ b/docker/compose/docker-compose.ultra-lite.yml @@ -44,6 +44,9 @@ services: - "3000:80" environment: BACKEND_URL: http://backend:8080 + #VITE_GOOGLE_DRIVE_CLIENT_ID: + #VITE_GOOGLE_DRIVE_API_KEY: + #VITE_GOOGLE_DRIVE_APP_ID: depends_on: - backend networks: diff --git a/docker/compose/docker-compose.yml b/docker/compose/docker-compose.yml index b0061f785..6f8b1ace8 100644 --- a/docker/compose/docker-compose.yml +++ b/docker/compose/docker-compose.yml @@ -46,6 +46,9 @@ services: - "3000:80" environment: BACKEND_URL: http://backend:8080 + #VITE_GOOGLE_DRIVE_CLIENT_ID: + #VITE_GOOGLE_DRIVE_API_KEY: + #VITE_GOOGLE_DRIVE_APP_ID: depends_on: - backend networks: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f73143e83..a27c2c936 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -66,6 +66,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 +2013,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 +3621,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", diff --git a/frontend/package.json b/frontend/package.json index 319653af1..901704500 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -105,6 +105,10 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/gapi": "^0.0.47", + "@types/gapi.client.drive-v3": "^0.0.5", + "@types/google.accounts": "^0.0.18", + "@types/google.picker": "^0.0.51", "@types/node": "^24.5.2", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index fb26a3a3a..96185902b 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -9,6 +9,8 @@ import MobileLayout from './fileManager/MobileLayout'; import DesktopLayout from './fileManager/DesktopLayout'; import DragOverlay from './fileManager/DragOverlay'; import { FileManagerProvider } from '../contexts/FileManagerContext'; +import { isGoogleDriveConfigured } from '../services/googleDrivePickerService'; +import { loadScript } from '../utils/scriptLoader'; interface FileManagerProps { selectedTool?: Tool | null; @@ -84,6 +86,29 @@ const FileManager: React.FC = ({ selectedTool }) => { }; }, []); + // Preload Google Drive scripts if configured + useEffect(() => { + if (isGoogleDriveConfigured()) { + // Load scripts in parallel without blocking + Promise.all([ + loadScript({ + src: 'https://apis.google.com/js/api.js', + id: 'gapi-script', + async: true, + defer: true, + }), + loadScript({ + src: 'https://accounts.google.com/gsi/client', + id: 'gis-script', + async: true, + defer: true, + }), + ]).catch((error) => { + console.warn('Failed to preload Google Drive scripts:', error); + }); + } + }, []); + // Modal size constants for consistent scaling const modalHeight = '80vh'; const modalWidth = isMobile ? '100%' : '80vw'; diff --git a/frontend/src/components/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx index d2d28e09e..78ab8ce39 100644 --- a/frontend/src/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/components/fileManager/FileSourceButtons.tsx @@ -5,6 +5,7 @@ import UploadIcon from '@mui/icons-material/Upload'; import CloudIcon from '@mui/icons-material/Cloud'; import { useTranslation } from 'react-i18next'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker'; interface FileSourceButtonsProps { horizontal?: boolean; @@ -13,8 +14,20 @@ interface FileSourceButtonsProps { const FileSourceButtons: React.FC = ({ horizontal = false }) => { - const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext(); + const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext(); const { t } = useTranslation(); + const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker(); + + const handleGoogleDriveClick = async () => { + try { + const files = await openGoogleDrivePicker({ multiple: true }); + if (files.length > 0) { + onGoogleDriveSelect(files); + } + } catch (error) { + console.error('Failed to pick files from Google Drive:', error); + } + }; const buttonProps = { variant: (source: string) => activeSource === source ? 'filled' : 'subtle', @@ -67,15 +80,24 @@ const FileSourceButtons: React.FC = ({ diff --git a/frontend/src/components/shared/config/configSections/Overview.tsx b/frontend/src/components/shared/config/configSections/Overview.tsx index e591655e0..d3f250f49 100644 --- a/frontend/src/components/shared/config/configSections/Overview.tsx +++ b/frontend/src/components/shared/config/configSections/Overview.tsx @@ -51,7 +51,6 @@ const Overview: React.FC = () => { } : null; const integrationConfig = config ? { - GoogleDriveEnabled: config.GoogleDriveEnabled, SSOAutoLogin: config.SSOAutoLogin, } : null; diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index 28a30fc20..8c7f6182d 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -39,6 +39,7 @@ interface FileManagerContextValue { onAddToRecents: (file: StirlingFileStub) => void; onUnzipFile: (file: StirlingFileStub) => Promise; onNewFilesSelect: (files: File[]) => void; + onGoogleDriveSelect: (files: File[]) => void; // External props recentFiles: StirlingFileStub[]; @@ -546,6 +547,19 @@ export const FileManagerProvider: React.FC = ({ } }, [refreshRecentFiles]); + const handleGoogleDriveSelect = useCallback(async (files: File[]) => { + if (files.length > 0) { + try { + // Process Google Drive files same as local files + onNewFilesSelect(files); + await refreshRecentFiles(); + onClose(); + } catch (error) { + console.error('Failed to process Google Drive files:', error); + } + } + }, [onNewFilesSelect, refreshRecentFiles, onClose]); + const handleUnzipFile = useCallback(async (file: StirlingFileStub) => { try { // Load the full file from storage @@ -623,6 +637,7 @@ export const FileManagerProvider: React.FC = ({ onAddToRecents: handleAddToRecents, onUnzipFile: handleUnzipFile, onNewFilesSelect, + onGoogleDriveSelect: handleGoogleDriveSelect, // External props recentFiles, @@ -656,6 +671,7 @@ export const FileManagerProvider: React.FC = ({ handleAddToRecents, handleUnzipFile, onNewFilesSelect, + handleGoogleDriveSelect, recentFiles, isFileSupported, modalHeight, diff --git a/frontend/src/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts index cb23cfb60..cfca99b57 100644 --- a/frontend/src/hooks/useAppConfig.ts +++ b/frontend/src/hooks/useAppConfig.ts @@ -21,7 +21,6 @@ export interface AppConfig { runningProOrHigher?: boolean; runningEE?: boolean; license?: string; - GoogleDriveEnabled?: boolean; SSOAutoLogin?: boolean; serverCertificateEnabled?: boolean; error?: string; diff --git a/frontend/src/hooks/useGoogleDrivePicker.ts b/frontend/src/hooks/useGoogleDrivePicker.ts new file mode 100644 index 000000000..5c9ead572 --- /dev/null +++ b/frontend/src/hooks/useGoogleDrivePicker.ts @@ -0,0 +1,98 @@ +/** + * React hook for Google Drive file picker + */ + +import { useState, useCallback, useEffect } from 'react'; +import { + getGoogleDrivePickerService, + isGoogleDriveConfigured, + getGoogleDriveConfig, +} from '../services/googleDrivePickerService'; + +interface UseGoogleDrivePickerOptions { + multiple?: boolean; + mimeTypes?: string; +} + +interface UseGoogleDrivePickerReturn { + isEnabled: boolean; + isLoading: boolean; + error: string | null; + openPicker: (options?: UseGoogleDrivePickerOptions) => Promise; +} + +/** + * Hook to use Google Drive file picker + */ +export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn { + const [isEnabled, setIsEnabled] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + + // Check if Google Drive is configured on mount + useEffect(() => { + const configured = isGoogleDriveConfigured(); + setIsEnabled(configured); + }, []); + + /** + * Initialize the Google Drive service (lazy initialization) + */ + const initializeService = useCallback(async () => { + if (isInitialized) return; + + const config = getGoogleDriveConfig(); + if (!config) { + throw new Error('Google Drive is not configured'); + } + + const service = getGoogleDrivePickerService(); + await service.initialize(config); + setIsInitialized(true); + }, [isInitialized]); + + /** + * Open the Google Drive picker + */ + const openPicker = useCallback( + async (options: UseGoogleDrivePickerOptions = {}): Promise => { + if (!isEnabled) { + setError('Google Drive is not configured'); + return []; + } + + try { + setIsLoading(true); + setError(null); + + // Initialize service if needed + await initializeService(); + + // Open picker + const service = getGoogleDrivePickerService(); + const files = await service.openPicker({ + multiple: options.multiple ?? true, + mimeTypes: options.mimeTypes, + }); + + return files; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to open Google Drive picker'; + setError(errorMessage); + console.error('Google Drive picker error:', err); + return []; + } finally { + setIsLoading(false); + } + }, + [isEnabled, initializeService] + ); + + return { + isEnabled, + isLoading, + error, + openPicker, + }; +} diff --git a/frontend/src/services/googleDrivePickerService.ts b/frontend/src/services/googleDrivePickerService.ts new file mode 100644 index 000000000..cb5b02b87 --- /dev/null +++ b/frontend/src/services/googleDrivePickerService.ts @@ -0,0 +1,295 @@ +/** + * Google Drive Picker Service + * Handles Google Drive file picker integration + */ + +import { loadScript } from '../utils/scriptLoader'; + +const SCOPES = 'https://www.googleapis.com/auth/drive.readonly'; +const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken'; + +interface GoogleDriveConfig { + clientId: string; + apiKey: string; + appId: string; +} + +interface PickerOptions { + multiple?: boolean; + mimeTypes?: string | null; +} + +// Expandable mime types for Google Picker +const expandableMimeTypes: Record = { + 'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'], +}; + +/** + * Convert file input accept attribute to Google Picker mime types + */ +function fileInputToGooglePickerMimeTypes(accept?: string): string | null { + if (!accept || accept === '' || accept.includes('*/*')) { + // Setting null will accept all supported mimetypes + return null; + } + + const mimeTypes: string[] = []; + accept.split(',').forEach((part) => { + const trimmedPart = part.trim(); + if (!(trimmedPart in expandableMimeTypes)) { + mimeTypes.push(trimmedPart); + return; + } + + expandableMimeTypes[trimmedPart].forEach((mimeType) => { + mimeTypes.push(mimeType); + }); + }); + + return mimeTypes.join(',').replace(/\s+/g, ''); +} + +class GoogleDrivePickerService { + private config: GoogleDriveConfig | null = null; + private tokenClient: any = null; + private accessToken: string | null = null; + private gapiLoaded = false; + private gisLoaded = false; + + constructor() { + this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID); + } + + /** + * Initialize the service with credentials + */ + async initialize(config: GoogleDriveConfig): Promise { + this.config = config; + + // Load Google APIs + await Promise.all([ + this.loadGapi(), + this.loadGis(), + ]); + } + + /** + * Load Google API client + */ + private async loadGapi(): Promise { + if (this.gapiLoaded) return; + + await loadScript({ + src: 'https://apis.google.com/js/api.js', + id: 'gapi-script', + }); + + return new Promise((resolve) => { + window.gapi.load('client:picker', async () => { + await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'); + this.gapiLoaded = true; + resolve(); + }); + }); + } + + /** + * Load Google Identity Services + */ + private async loadGis(): Promise { + if (this.gisLoaded) return; + + await loadScript({ + src: 'https://accounts.google.com/gsi/client', + id: 'gis-script', + }); + + if (!this.config) { + throw new Error('Google Drive config not initialized'); + } + + this.tokenClient = window.google.accounts.oauth2.initTokenClient({ + client_id: this.config.clientId, + scope: SCOPES, + callback: () => {}, // Will be overridden during picker creation + }); + + this.gisLoaded = true; + } + + /** + * Open the Google Drive picker + */ + async openPicker(options: PickerOptions = {}): Promise { + if (!this.config) { + throw new Error('Google Drive service not initialized'); + } + + // Request access token + await this.requestAccessToken(); + + // Create and show picker + return this.createPicker(options); + } + + /** + * Request access token from Google + */ + private requestAccessToken(): Promise { + return new Promise((resolve, reject) => { + if (!this.tokenClient) { + reject(new Error('Token client not initialized')); + return; + } + + this.tokenClient.callback = (response: any) => { + if (response.error !== undefined) { + reject(new Error(response.error)); + return; + } + if(response.access_token == null){ + reject(new Error("No acces token in response")); + } + + this.accessToken = response.access_token; + sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? ""); + resolve(); + }; + + this.tokenClient.requestAccessToken({ + prompt: this.accessToken === null ? 'consent' : '', + }); + }); + } + + /** + * Create and display the Google Picker + */ + private createPicker(options: PickerOptions): Promise { + return new Promise((resolve, reject) => { + if (!this.config || !this.accessToken) { + reject(new Error('Not initialized or no access token')); + return; + } + + const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined); + + const view1 = new window.google.picker.DocsView().setIncludeFolders(true); + if (mimeTypes !== null) { + view1.setMimeTypes(mimeTypes); + } + + const view2 = new window.google.picker.DocsView() + .setIncludeFolders(true) + .setEnableDrives(true); + if (mimeTypes !== null) { + view2.setMimeTypes(mimeTypes); + } + + const builder = new window.google.picker.PickerBuilder() + .setDeveloperKey(this.config.apiKey) + .setAppId(this.config.appId) + .setOAuthToken(this.accessToken) + .addView(view1) + .addView(view2) + .setCallback((data: any) => this.pickerCallback(data, resolve, reject)); + + if (options.multiple) { + builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED); + } + + const picker = builder.build(); + picker.setVisible(true); + }); + } + + /** + * Handle picker selection callback + */ + private async pickerCallback( + data: any, + resolve: (files: File[]) => void, + reject: (error: Error) => void + ): Promise { + if (data.action === window.google.picker.Action.PICKED) { + try { + const files = await Promise.all( + data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => { + const fileId = pickedFile[window.google.picker.Document.ID]; + const res = await window.gapi.client.drive.files.get({ + fileId: fileId, + alt: 'media', + }); + + // Convert response body to File object + const file = new File( + [new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))], + pickedFile.name, + { + type: pickedFile.mimeType, + lastModified: pickedFile.lastModified, + } + ); + return file; + }) + ); + + resolve(files); + } catch (error) { + reject(error instanceof Error ? error : new Error('Failed to download files')); + } + } else if (data.action === window.google.picker.Action.CANCEL) { + resolve([]); // User cancelled, return empty array + } + } + + /** + * Sign out and revoke access token + */ + signOut(): void { + if (this.accessToken) { + sessionStorage.removeItem(SESSION_STORAGE_ID); + window.google?.accounts.oauth2.revoke(this.accessToken, () => {}); + this.accessToken = null; + } + } +} + +// Singleton instance +let serviceInstance: GoogleDrivePickerService | null = null; + +/** + * Get or create the Google Drive picker service instance + */ +export function getGoogleDrivePickerService(): GoogleDrivePickerService { + if (!serviceInstance) { + serviceInstance = new GoogleDrivePickerService(); + } + return serviceInstance; +} + +/** + * Check if Google Drive credentials are configured + */ +export function isGoogleDriveConfigured(): boolean { + const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID; + const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY; + const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID; + + return !!(clientId && apiKey && appId); +} + +/** + * Get Google Drive configuration from environment variables + */ +export function getGoogleDriveConfig(): GoogleDriveConfig | null { + if (!isGoogleDriveConfigured()) { + return null; + } + + return { + clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID, + apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY, + appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID, + }; +} diff --git a/frontend/src/utils/scriptLoader.ts b/frontend/src/utils/scriptLoader.ts new file mode 100644 index 000000000..bf0b64ded --- /dev/null +++ b/frontend/src/utils/scriptLoader.ts @@ -0,0 +1,55 @@ +/** + * Utility for dynamically loading external scripts + */ + +interface ScriptLoadOptions { + src: string; + id?: string; + async?: boolean; + defer?: boolean; + onLoad?: () => void; +} + +const loadedScripts = new Set(); + +export function loadScript({ src, id, async = true, defer = false, onLoad }: ScriptLoadOptions): Promise { + return new Promise((resolve, reject) => { + // Check if already loaded + const scriptId = id || src; + if (loadedScripts.has(scriptId)) { + resolve(); + return; + } + + // Check if script already exists in DOM + const existingScript = id ? document.getElementById(id) : document.querySelector(`script[src="${src}"]`); + if (existingScript) { + loadedScripts.add(scriptId); + resolve(); + return; + } + + // Create and append script + const script = document.createElement('script'); + script.src = src; + if (id) script.id = id; + script.async = async; + script.defer = defer; + + script.onload = () => { + loadedScripts.add(scriptId); + if (onLoad) onLoad(); + resolve(); + }; + + script.onerror = () => { + reject(new Error(`Failed to load script: ${src}`)); + }; + + document.head.appendChild(script); + }); +} + +export function isScriptLoaded(idOrSrc: string): boolean { + return loadedScripts.has(idOrSrc); +} From b695e3900e187ba73badb3fe82305db73d2f23f2 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 9 Oct 2025 13:35:42 +0100 Subject: [PATCH 4/4] Feature/v2/improve sign (#4627) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: James Brunton Co-authored-by: Claude --- frontend/package-lock.json | 7 + frontend/package.json | 1 + .../public/locales/en-GB/translation.json | 10 +- .../annotation/shared/DrawingCanvas.tsx | 540 +++++++----------- .../annotation/shared/ImageUploader.tsx | 2 +- .../annotation/shared/TextInputWithFont.tsx | 154 +++-- .../rightRail/ViewerAnnotationControls.tsx | 14 +- .../components/tools/sign/SignSettings.tsx | 70 ++- .../src/components/viewer/EmbedPdfViewer.tsx | 2 + .../src/components/viewer/LocalEmbedPDF.tsx | 2 +- .../components/viewer/SignatureAPIBridge.tsx | 163 +----- frontend/src/contexts/file/FileReducer.ts | 5 +- .../src/hooks/tools/sign/useSignParameters.ts | 2 + frontend/src/tools/Sign.tsx | 91 +-- frontend/src/utils/signatureFlattening.ts | 52 +- package-lock.json | 6 + 16 files changed, 499 insertions(+), 622 deletions(-) create mode 100644 package-lock.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a27c2c936..51e500cb7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -54,6 +54,7 @@ "react-dom": "^19.1.1", "react-i18next": "^15.7.3", "react-router-dom": "^7.9.1", + "signature_pad": "^5.0.4", "tailwindcss": "^4.1.13", "web-vitals": "^5.1.0" }, @@ -9992,6 +9993,12 @@ "dev": true, "license": "ISC" }, + "node_modules/signature_pad": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/signature_pad/-/signature_pad-5.1.1.tgz", + "integrity": "sha512-BT5JJygS5BS0oV+tffPRorIud6q17bM7v/1LdQwd0o6mTqGoI25yY1NjSL99OqkekWltS4uon6p52Y8j1Zqu7g==", + "license": "MIT" + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 901704500..4eb01f202 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -50,6 +50,7 @@ "react-dom": "^19.1.1", "react-i18next": "^15.7.3", "react-router-dom": "^7.9.1", + "signature_pad": "^5.0.4", "tailwindcss": "^4.1.13", "web-vitals": "^5.1.0" }, diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 1be334754..d633c5163 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1819,8 +1819,16 @@ "placeholder": "Enter your full name" }, "instructions": { - "title": "How to add signature" + "title": "How to add signature", + "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", + "image": "After uploading your signature image above, click anywhere on the PDF to place it.", + "text": "After entering your name above, click anywhere on the PDF to place your signature." }, + "mode": { + "move": "Move Signature", + "place": "Place Signature" + }, + "updateAndPlace": "Update and Place", "activate": "Activate Signature Placement", "deactivate": "Stop Placing Signatures", "results": { diff --git a/frontend/src/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/components/annotation/shared/DrawingCanvas.tsx index d4ae74ad0..87362f74d 100644 --- a/frontend/src/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/components/annotation/shared/DrawingCanvas.tsx @@ -1,7 +1,8 @@ -import React, { useRef, useState, useCallback } from 'react'; -import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core'; +import React, { useRef, useState } from 'react'; +import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core'; import { ColorSwatchButton } from './ColorPicker'; import PenSizeSelector from '../../tools/sign/PenSizeSelector'; +import SignaturePad from 'signature_pad'; interface DrawingCanvasProps { selectedColor: string; @@ -11,6 +12,7 @@ interface DrawingCanvasProps { onPenSizeChange: (size: number) => void; onPenSizeInputChange: (input: string) => void; onSignatureDataChange: (data: string | null) => void; + onDrawingComplete?: () => void; disabled?: boolean; width?: number; height?: number; @@ -27,411 +29,253 @@ export const DrawingCanvas: React.FC = ({ onPenSizeChange, onPenSizeInputChange, onSignatureDataChange, + onDrawingComplete, disabled = false, width = 400, height = 150, - modalWidth = 800, - modalHeight = 400, - additionalButtons }) => { - const canvasRef = useRef(null); + const previewCanvasRef = useRef(null); const modalCanvasRef = useRef(null); - const visibleModalCanvasRef = useRef(null); + const padRef = useRef(null); + const [modalOpen, setModalOpen] = useState(false); + const [colorPickerOpen, setColorPickerOpen] = useState(false); - const [isDrawing, setIsDrawing] = useState(false); - const [isModalDrawing, setIsModalDrawing] = useState(false); - const [isModalOpen, setIsModalOpen] = useState(false); + const initPad = (canvas: HTMLCanvasElement) => { + if (!padRef.current) { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; - // Drawing functions for main canvas - const startDrawing = useCallback((e: React.MouseEvent) => { - if (!canvasRef.current || disabled) return; - - setIsDrawing(true); - const rect = canvasRef.current.getBoundingClientRect(); - const scaleX = canvasRef.current.width / rect.width; - const scaleY = canvasRef.current.height / rect.height; - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - - const ctx = canvasRef.current.getContext('2d'); - if (ctx) { - ctx.strokeStyle = selectedColor; - ctx.lineWidth = penSize; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.beginPath(); - ctx.moveTo(x, y); + padRef.current = new SignaturePad(canvas, { + penColor: selectedColor, + minWidth: penSize * 0.5, + maxWidth: penSize * 2.5, + throttle: 10, + minDistance: 5, + velocityFilterWeight: 0.7, + }); } - }, [disabled, selectedColor, penSize]); + }; - const draw = useCallback((e: React.MouseEvent) => { - if (!isDrawing || !canvasRef.current || disabled) return; - - const rect = canvasRef.current.getBoundingClientRect(); - const scaleX = canvasRef.current.width / rect.width; - const scaleY = canvasRef.current.height / rect.height; - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - - const ctx = canvasRef.current.getContext('2d'); - if (ctx) { - ctx.lineTo(x, y); - ctx.stroke(); + const openModal = () => { + // Clear pad ref so it reinitializes + if (padRef.current) { + padRef.current.off(); + padRef.current = null; } - }, [isDrawing, disabled]); + setModalOpen(true); + }; - const stopDrawing = useCallback(() => { - if (!isDrawing || disabled) return; + const trimCanvas = (canvas: HTMLCanvasElement): string => { + const ctx = canvas.getContext('2d'); + if (!ctx) return canvas.toDataURL('image/png'); - setIsDrawing(false); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imageData.data; - // Save canvas as signature data - if (canvasRef.current) { - const dataURL = canvasRef.current.toDataURL('image/png'); - onSignatureDataChange(dataURL); - } - }, [isDrawing, disabled, onSignatureDataChange]); + let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0; - // Modal canvas drawing functions - const startModalDrawing = useCallback((e: React.MouseEvent) => { - if (!visibleModalCanvasRef.current || !modalCanvasRef.current) return; - - setIsModalDrawing(true); - const rect = visibleModalCanvasRef.current.getBoundingClientRect(); - const scaleX = visibleModalCanvasRef.current.width / rect.width; - const scaleY = visibleModalCanvasRef.current.height / rect.height; - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - - // Draw on both the visible modal canvas and hidden canvas - const visibleCtx = visibleModalCanvasRef.current.getContext('2d'); - const hiddenCtx = modalCanvasRef.current.getContext('2d'); - - [visibleCtx, hiddenCtx].forEach(ctx => { - if (ctx) { - ctx.strokeStyle = selectedColor; - ctx.lineWidth = penSize; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.beginPath(); - ctx.moveTo(x, y); - } - }); - }, [selectedColor, penSize]); - - const drawModal = useCallback((e: React.MouseEvent) => { - if (!isModalDrawing || !visibleModalCanvasRef.current || !modalCanvasRef.current) return; - - const rect = visibleModalCanvasRef.current.getBoundingClientRect(); - const scaleX = visibleModalCanvasRef.current.width / rect.width; - const scaleY = visibleModalCanvasRef.current.height / rect.height; - const x = (e.clientX - rect.left) * scaleX; - const y = (e.clientY - rect.top) * scaleY; - - // Draw on both canvases - const visibleCtx = visibleModalCanvasRef.current.getContext('2d'); - const hiddenCtx = modalCanvasRef.current.getContext('2d'); - - [visibleCtx, hiddenCtx].forEach(ctx => { - if (ctx) { - ctx.lineTo(x, y); - ctx.stroke(); - } - }); - }, [isModalDrawing]); - - const stopModalDrawing = useCallback(() => { - if (!isModalDrawing) return; - setIsModalDrawing(false); - - // Sync the canvases and update signature data (only when drawing stops) - if (modalCanvasRef.current) { - const dataURL = modalCanvasRef.current.toDataURL('image/png'); - onSignatureDataChange(dataURL); - - // Also update the small canvas display - if (canvasRef.current) { - const smallCtx = canvasRef.current.getContext('2d'); - if (smallCtx) { - const img = new Image(); - img.onload = () => { - smallCtx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height); - smallCtx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height); - }; - img.src = dataURL; + // Find bounds of non-transparent pixels + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + const alpha = pixels[(y * canvas.width + x) * 4 + 3]; + if (alpha > 0) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; } } } - }, [isModalDrawing]); - // Clear canvas functions - const clearCanvas = useCallback(() => { - if (!canvasRef.current || disabled) return; + const trimWidth = maxX - minX + 1; + const trimHeight = maxY - minY + 1; - const ctx = canvasRef.current.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); - - // Also clear the modal canvas if it exists - if (modalCanvasRef.current) { - const modalCtx = modalCanvasRef.current.getContext('2d'); - if (modalCtx) { - modalCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height); - } - } - - onSignatureDataChange(null); - } - }, [disabled]); - - const clearModalCanvas = useCallback(() => { - // Clear both modal canvases (visible and hidden) - if (modalCanvasRef.current) { - const hiddenCtx = modalCanvasRef.current.getContext('2d'); - if (hiddenCtx) { - hiddenCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height); - } + // Create trimmed canvas + const trimmedCanvas = document.createElement('canvas'); + trimmedCanvas.width = trimWidth; + trimmedCanvas.height = trimHeight; + const trimmedCtx = trimmedCanvas.getContext('2d'); + if (trimmedCtx) { + trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight); } - if (visibleModalCanvasRef.current) { - const visibleCtx = visibleModalCanvasRef.current.getContext('2d'); - if (visibleCtx) { - visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height); - } - } + return trimmedCanvas.toDataURL('image/png'); + }; - // Also clear the main canvas and signature data - if (canvasRef.current) { - const mainCtx = canvasRef.current.getContext('2d'); - if (mainCtx) { - mainCtx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); - } - } + const closeModal = () => { + if (padRef.current && !padRef.current.isEmpty()) { + const canvas = modalCanvasRef.current; + if (canvas) { + const trimmedPng = trimCanvas(canvas); + onSignatureDataChange(trimmedPng); - onSignatureDataChange(null); - }, []); - - const saveModalSignature = useCallback(() => { - if (!modalCanvasRef.current) return; - - const dataURL = modalCanvasRef.current.toDataURL('image/png'); - onSignatureDataChange(dataURL); - - // Copy to small canvas for display - if (canvasRef.current) { - const ctx = canvasRef.current.getContext('2d'); - if (ctx) { + // Update preview canvas with proper aspect ratio const img = new Image(); img.onload = () => { - ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height); - ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height); + if (previewCanvasRef.current) { + const ctx = previewCanvasRef.current.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); + + // Calculate scaling to fit within preview canvas while maintaining aspect ratio + const scale = Math.min( + previewCanvasRef.current.width / img.width, + previewCanvasRef.current.height / img.height + ); + const scaledWidth = img.width * scale; + const scaledHeight = img.height * scale; + const x = (previewCanvasRef.current.width - scaledWidth) / 2; + const y = (previewCanvasRef.current.height - scaledHeight) / 2; + + ctx.drawImage(img, x, y, scaledWidth, scaledHeight); + } + } }; - img.src = dataURL; - } - } + img.src = trimmedPng; - setIsModalOpen(false); - }, []); - - const openModal = useCallback(() => { - setIsModalOpen(true); - // Copy content to modal canvas after a brief delay - setTimeout(() => { - if (visibleModalCanvasRef.current && modalCanvasRef.current) { - const visibleCtx = visibleModalCanvasRef.current.getContext('2d'); - if (visibleCtx) { - visibleCtx.strokeStyle = selectedColor; - visibleCtx.lineWidth = penSize; - visibleCtx.lineCap = 'round'; - visibleCtx.lineJoin = 'round'; - visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height); - visibleCtx.drawImage(modalCanvasRef.current, 0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height); + if (onDrawingComplete) { + onDrawingComplete(); } } - }, 300); - }, [selectedColor, penSize]); + } + if (padRef.current) { + padRef.current.off(); + padRef.current = null; + } + setModalOpen(false); + }; - // Initialize canvas settings whenever color or pen size changes - React.useEffect(() => { - const updateCanvas = (canvas: HTMLCanvasElement | null) => { - if (!canvas) return; - const ctx = canvas.getContext('2d'); + const clear = () => { + if (padRef.current) { + padRef.current.clear(); + } + if (previewCanvasRef.current) { + const ctx = previewCanvasRef.current.getContext('2d'); if (ctx) { - ctx.strokeStyle = selectedColor; - ctx.lineWidth = penSize; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; + ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); } - }; + } + onSignatureDataChange(null); + }; - updateCanvas(canvasRef.current); - updateCanvas(modalCanvasRef.current); - updateCanvas(visibleModalCanvasRef.current); - }, [selectedColor, penSize]); + const updatePenColor = (color: string) => { + if (padRef.current) { + padRef.current.penColor = color; + } + }; + + const updatePenSize = (size: number) => { + if (padRef.current) { + padRef.current.minWidth = size * 0.8; + padRef.current.maxWidth = size * 1.2; + } + }; return ( <> - - Draw your signature - -
- Color - - - -
-
- Pen Size - -
-
- -
-
-
+ Draw your signature - -
- {additionalButtons} -
- -
+ + Click to open drawing canvas +
- {/* Hidden canvas for modal synchronization */} - - - {/* Modal for larger signature canvas */} - setIsModalOpen(false)} - title="Draw Your Signature" - size="xl" - centered - > + - {/* Color and Pen Size picker */} - - -
- Color - -
-
- Pen Size - -
-
-
+
+
+ Color + + +
+ setColorPickerOpen(!colorPickerOpen)} + /> +
+
+ + { + onColorSwatchClick(); + updatePenColor(color); + }} + swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']} + /> + +
+
+
+ Pen Size + { + onPenSizeChange(size); + updatePenSize(size); + }} + onInputChange={onPenSizeInputChange} + placeholder="Size" + size="compact-sm" + style={{ width: '60px' }} + /> +
+
- - - + { + 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', + }} + /> - - - - - - - + +
); }; -export default DrawingCanvas; \ No newline at end of file +export default DrawingCanvas; diff --git a/frontend/src/components/annotation/shared/ImageUploader.tsx b/frontend/src/components/annotation/shared/ImageUploader.tsx index d590a7bc1..aabca815c 100644 --- a/frontend/src/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/components/annotation/shared/ImageUploader.tsx @@ -48,7 +48,7 @@ export const ImageUploader: React.FC = ({ disabled={disabled} /> - {hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')} + {hint || t('sign.image.hint', 'Upload an image of your signature')} ); diff --git a/frontend/src/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/components/annotation/shared/TextInputWithFont.tsx index b85511cd5..b7af60295 100644 --- a/frontend/src/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/components/annotation/shared/TextInputWithFont.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core'; +import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { ColorPicker } from './ColorPicker'; interface TextInputWithFontProps { text: string; @@ -9,6 +10,8 @@ interface TextInputWithFontProps { onFontSizeChange: (size: number) => void; fontFamily: string; onFontFamilyChange: (family: string) => void; + textColor?: string; + onTextColorChange?: (color: string) => void; disabled?: boolean; label?: string; placeholder?: string; @@ -21,6 +24,8 @@ export const TextInputWithFont: React.FC = ({ onFontSizeChange, fontFamily, onFontFamilyChange, + textColor = '#000000', + onTextColorChange, disabled = false, label, placeholder @@ -28,6 +33,7 @@ export const TextInputWithFont: React.FC = ({ const { t } = useTranslation(); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); const fontSizeCombobox = useCombobox(); + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); // Sync font size input with prop changes useEffect(() => { @@ -42,7 +48,7 @@ export const TextInputWithFont: React.FC = ({ { value: 'Georgia', label: 'Georgia' }, ]; - const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48']; + const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200']; return ( @@ -66,61 +72,101 @@ export const TextInputWithFont: React.FC = ({ allowDeselect={false} /> - {/* Font Size */} - { - setFontSizeInput(optionValue); - const size = parseInt(optionValue); - if (!isNaN(size)) { - onFontSizeChange(size); - } - fontSizeCombobox.closeDropdown(); - }} - store={fontSizeCombobox} - withinPortal={false} - > - - { - const value = event.currentTarget.value; - setFontSizeInput(value); + {/* Font Size and Color */} + + { + setFontSizeInput(optionValue); + const size = parseInt(optionValue); + if (!isNaN(size)) { + onFontSizeChange(size); + } + fontSizeCombobox.closeDropdown(); + }} + store={fontSizeCombobox} + withinPortal={false} + > + + { + const value = event.currentTarget.value; + setFontSizeInput(value); - // Parse and validate the typed value in real-time - const size = parseInt(value); - if (!isNaN(size) && size >= 8 && size <= 72) { - onFontSizeChange(size); + // Parse and validate the typed value in real-time + const size = parseInt(value); + if (!isNaN(size) && size >= 8 && size <= 200) { + onFontSizeChange(size); + } + + fontSizeCombobox.openDropdown(); + fontSizeCombobox.updateSelectedOptionIndex(); + }} + onClick={() => fontSizeCombobox.openDropdown()} + onFocus={() => fontSizeCombobox.openDropdown()} + onBlur={() => { + fontSizeCombobox.closeDropdown(); + // Clean up invalid values on blur + const size = parseInt(fontSizeInput); + if (isNaN(size) || size < 8 || size > 200) { + setFontSizeInput(fontSize.toString()); + } else { + onFontSizeChange(size); + } + }} + disabled={disabled} + /> + + + + + {fontSizeOptions.map((size) => ( + + {size}px + + ))} + + + + + {/* Text Color Picker */} + {onTextColorChange && ( + + !disabled && setIsColorPickerOpen(true)} + style={{ cursor: disabled ? 'default' : 'pointer' }} + rightSection={ + } + /> + + )} + - fontSizeCombobox.openDropdown(); - fontSizeCombobox.updateSelectedOptionIndex(); - }} - onClick={() => fontSizeCombobox.openDropdown()} - onFocus={() => fontSizeCombobox.openDropdown()} - onBlur={() => { - fontSizeCombobox.closeDropdown(); - // Clean up invalid values on blur - const size = parseInt(fontSizeInput); - if (isNaN(size) || size < 8 || size > 72) { - setFontSizeInput(fontSize.toString()); - } - }} - disabled={disabled} - /> - - - - - {fontSizeOptions.map((size) => ( - - {size}px - - ))} - - - + {/* Color Picker Modal */} + {onTextColorChange && ( + setIsColorPickerOpen(false)} + selectedColor={textColor} + onColorChange={onTextColorChange} + /> + )} ); }; \ No newline at end of file diff --git a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx index 92b778e44..0b9d9a754 100644 --- a/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -10,6 +10,7 @@ import { useFileState, useFileContext } from '../../../contexts/FileContext'; import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils'; import { createProcessedFile } from '../../../contexts/file/fileActions'; import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext'; +import { useNavigationState } from '../../../contexts/NavigationContext'; interface ViewerAnnotationControlsProps { currentView: string; @@ -25,13 +26,17 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati 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) { @@ -39,6 +44,11 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati } }, [currentView, viewerContext]); + // Don't show any annotation controls in sign mode + if (isSignMode) { + return null; + } + return ( <> {/* Annotation Visibility Toggle */} @@ -50,7 +60,7 @@ export default function ViewerAnnotationControls({ currentView }: ViewerAnnotati onClick={() => { viewerContext?.toggleAnnotationsVisibility(); }} - disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode} + disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode} > { const { t } = useTranslation(); + const { isPlacementMode } = useSignature(); // State for drawing const [selectedColor, setSelectedColor] = useState('#000000'); const [penSize, setPenSize] = useState(2); const [penSizeInput, setPenSizeInput] = useState('2'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move'); // State for different signature types const [canvasSignatureData, setCanvasSignatureData] = useState(null); @@ -96,20 +99,29 @@ const SignSettings = ({ } }, [parameters.signatureType]); - // Handle text signature activation + // Handle text signature activation (including fontSize and fontFamily changes) useEffect(() => { if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') { if (onActivateSignaturePlacement) { + setInteractionMode('place'); setTimeout(() => { onActivateSignaturePlacement(); }, 100); } } else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) { if (onDeactivateSignature) { + setInteractionMode('move'); onDeactivateSignature(); } } - }, [parameters.signatureType, parameters.signerName, onActivateSignaturePlacement, onDeactivateSignature]); + }, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]); + + // Reset to move mode when placement mode is deactivated + useEffect(() => { + if (!isPlacementMode && interactionMode === 'place') { + setInteractionMode('move'); + } + }, [isPlacementMode, interactionMode]); // Handle signature data updates useEffect(() => { @@ -130,12 +142,23 @@ const SignSettings = ({ // Handle image signature activation - activate when image data syncs with parameters useEffect(() => { if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) { + setInteractionMode('place'); setTimeout(() => { onActivateSignaturePlacement(); }, 100); } }, [parameters.signatureType, parameters.signatureData, imageSignatureData]); + // Handle canvas signature activation - activate when canvas data syncs with parameters + useEffect(() => { + if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) { + setInteractionMode('place'); + setTimeout(() => { + onActivateSignaturePlacement(); + }, 100); + } + }, [parameters.signatureType, parameters.signatureData, canvasSignatureData]); + // Draw settings are no longer needed since draw mode is removed return ( @@ -170,7 +193,7 @@ const SignSettings = ({ hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))} disabled={disabled} showPlaceButton={false} - placeButtonText="Update and Place" + placeButtonText={t('sign.updateAndPlace', 'Update and Place')} /> {/* Signature Creation based on type */} @@ -183,6 +206,11 @@ const SignSettings = ({ onPenSizeChange={setPenSize} onPenSizeInputChange={setPenSizeInput} onSignatureDataChange={handleCanvasSignatureChange} + onDrawingComplete={() => { + if (onActivateSignaturePlacement) { + onActivateSignaturePlacement(); + } + }} disabled={disabled} additionalButtons={ } /> @@ -216,17 +244,43 @@ const SignSettings = ({ onFontSizeChange={(size) => onParameterChange('fontSize', size)} fontFamily={parameters.fontFamily || 'Helvetica'} onFontFamilyChange={(family) => onParameterChange('fontFamily', family)} + textColor={parameters.textColor || '#000000'} + onTextColorChange={(color) => onParameterChange('textColor', color)} disabled={disabled} /> )} + {/* Interaction Mode Toggle */} + {(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && ( + { + setInteractionMode(value as 'move' | 'place'); + if (value === 'place') { + if (onActivateSignaturePlacement) { + onActivateSignaturePlacement(); + } + } else { + if (onDeactivateSignature) { + onDeactivateSignature(); + } + } + }} + data={[ + { label: t('sign.mode.move', 'Move Signature'), value: 'move' }, + { label: t('sign.mode.place', 'Place Signature'), value: 'place' } + ]} + fullWidth + /> + )} + {/* Instructions for placing signature */} - {parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'} - {parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'} - {parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'} + {parameters.signatureType === 'canvas' && t('sign.instructions.canvas', 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.')} + {parameters.signatureType === 'image' && t('sign.instructions.image', 'After uploading your signature image above, click anywhere on the PDF to place it.')} + {parameters.signatureType === 'text' && t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.')} diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index f8c6d0e4f..71dbfa90b 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -13,6 +13,7 @@ import { useNavigationGuard, useNavigationState } from '../../contexts/Navigatio import { useSignature } from '../../contexts/SignatureContext'; import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers'; import NavigationWarningModal from '../shared/NavigationWarningModal'; +import { isStirlingFile } from '../../types/fileContext'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -263,6 +264,7 @@ const EmbedPdfViewerContent = ({ transition: 'margin-right 0.3s ease' }}> {/* Selection layer for text interaction */} - + {/* Annotation layer for signatures (only when enabled) */} {enableAnnotations && ( void; - addTextSignature: (text: string, x: number, y: number, pageIndex: number) => void; activateDrawMode: () => void; activateSignaturePlacementMode: () => void; activateDeleteMode: () => void; deleteAnnotation: (annotationId: string, pageIndex: number) => void; updateDrawSettings: (color: string, size: number) => void; deactivateTools: () => void; - applySignatureFromParameters: (params: SignParameters) => void; getPageAnnotations: (pageIndex: number) => Promise; } export const SignatureAPIBridge = forwardRef(function SignatureAPIBridge(_, ref) { const { provides: annotationApi } = useAnnotationCapability(); const { signatureConfig, storeImageData, isPlacementMode } = useSignature(); - const { isAnnotationMode } = useViewer(); - // Enable keyboard deletion of selected annotations - when in signature placement mode or viewer annotation mode + // Enable keyboard deletion of selected annotations useEffect(() => { - if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return; + // Always enable delete key when we have annotation API and are in sign mode + if (!annotationApi || (isPlacementMode === undefined)) return; - const handleKeyDown = (event: KeyboardEvent) => { + const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Delete' || event.key === 'Backspace') { const selectedAnnotation = annotationApi.getSelectedAnnotation?.(); @@ -67,7 +63,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [annotationApi, storeImageData, isPlacementMode, isAnnotationMode]); + }, [annotationApi, storeImageData, isPlacementMode]); useImperativeHandle(ref, () => ({ addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => { @@ -100,34 +96,6 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI }); }, - addTextSignature: (text: string, x: number, y: number, pageIndex: number) => { - if (!annotationApi) return; - - // Create text annotation for signature - annotationApi.createAnnotation(pageIndex, { - type: PdfAnnotationSubtype.FREETEXT, - rect: { - origin: { x, y }, - size: { width: 200, height: 50 } - }, - contents: text, - author: 'Digital Signature', - fontSize: 16, - fontColor: '#000000', - fontFamily: PdfStandardFont.Helvetica, - textAlign: PdfTextAlignment.Left, - verticalAlign: PdfVerticalAlignment.Top, - opacity: 1, - pageIndex: pageIndex, - id: uuidV4(), - created: new Date(), - customData: { - signatureText: text, - signatureType: 'text' - } - }); - }, - activateDrawMode: () => { if (!annotationApi) return; @@ -152,45 +120,31 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI try { if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) { - // Try different tool names for text annotations - const textToolNames = ['freetext', 'text', 'textbox', 'annotation-text']; - let activatedTool = null; - - for (const toolName of textToolNames) { - annotationApi.setActiveTool(toolName); - const tool = annotationApi.getActiveTool(); - - if (tool && tool.id === toolName) { - activatedTool = tool; - annotationApi.setToolDefaults(toolName, { - contents: signatureConfig.signerName, - fontSize: signatureConfig.fontSize || 16, - fontFamily: signatureConfig.fontFamily === 'Times-Roman' ? PdfStandardFont.Times_Roman : - signatureConfig.fontFamily === 'Courier' ? PdfStandardFont.Courier : - PdfStandardFont.Helvetica, - fontColor: '#000000', - }); - break; - } - } + // Skip native text tools - always use stamp for consistent sizing + const activatedTool = null; if (!activatedTool) { - // Fallback: create a simple text image as stamp + // Create text image as stamp with actual pixel size matching desired display size const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (ctx) { - const fontSize = signatureConfig.fontSize || 16; + const baseFontSize = signatureConfig.fontSize || 16; const fontFamily = signatureConfig.fontFamily || 'Helvetica'; + const textColor = signatureConfig.textColor || '#000000'; - canvas.width = Math.max(200, signatureConfig.signerName.length * fontSize * 0.6); - canvas.height = fontSize + 20; - ctx.fillStyle = '#000000'; - ctx.font = `${fontSize}px ${fontFamily}`; + // Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly) + canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6); + canvas.height = baseFontSize + 20; + + ctx.fillStyle = textColor; + ctx.font = `${baseFontSize}px ${fontFamily}`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2); const dataURL = canvas.toDataURL(); + // Deactivate and reactivate to force refresh + annotationApi.setActiveTool(null); annotationApi.setActiveTool('stamp'); const stampTool = annotationApi.getActiveTool(); if (stampTool && stampTool.id === 'stamp') { @@ -205,6 +159,7 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI // Use stamp tool for image/canvas signatures annotationApi.setActiveTool('stamp'); const activeTool = annotationApi.getActiveTool(); + if (activeTool && activeTool.id === 'stamp') { annotationApi.setToolDefaults('stamp', { imageSrc: signatureConfig.signatureData, @@ -267,84 +222,6 @@ export const SignatureAPIBridge = forwardRef(function SignatureAPI annotationApi.setActiveTool(null); }, - applySignatureFromParameters: (params: SignParameters) => { - if (!annotationApi || !params.signaturePosition) return; - - const { x, y, width, height, page } = params.signaturePosition; - - switch (params.signatureType) { - case 'image': - if (params.signatureData) { - const annotationId = uuidV4(); - - // Store image data in our persistent store - storeImageData(annotationId, params.signatureData); - - annotationApi.createAnnotation(page, { - type: PdfAnnotationSubtype.STAMP, - rect: { - origin: { x, y }, - size: { width, height } - }, - author: 'Digital Signature', - subject: `Digital Signature - ${params.reason || 'Document signing'}`, - pageIndex: page, - id: annotationId, - created: new Date(), - // Store image data in multiple places to ensure history captures it - imageSrc: params.signatureData, - contents: params.signatureData, // Some annotation systems use contents - data: params.signatureData, // Try data field - imageData: params.signatureData, // Try imageData field - appearance: params.signatureData // Try appearance field - }); - - // Switch to select mode after placing signature so it can be easily deleted - setTimeout(() => { - annotationApi.setActiveTool('select'); - }, 100); - } - break; - - case 'text': - if (params.signerName) { - annotationApi.createAnnotation(page, { - type: PdfAnnotationSubtype.FREETEXT, - rect: { - origin: { x, y }, - size: { width, height } - }, - contents: params.signerName, - author: 'Digital Signature', - fontSize: 16, - fontColor: '#000000', - fontFamily: PdfStandardFont.Helvetica, - textAlign: PdfTextAlignment.Left, - verticalAlign: PdfVerticalAlignment.Top, - opacity: 1, - pageIndex: page, - id: uuidV4(), - created: new Date(), - customData: { - signatureText: params.signerName, - signatureType: 'text' - } - }); - - // Switch to select mode after placing signature so it can be easily deleted - setTimeout(() => { - annotationApi.setActiveTool('select'); - }, 100); - } - break; - - case 'draw': - // For draw mode, we activate the tool and let user draw - annotationApi.setActiveTool('ink'); - break; - } - }, - getPageAnnotations: async (pageIndex: number): Promise => { if (!annotationApi || !annotationApi.getPageAnnotations) { console.warn('getPageAnnotations not available'); diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index a646b90fe..3811572c0 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -51,8 +51,9 @@ function processFileSwap( } }); - // Clear selections that reference removed files + // Clear selections that reference removed files and add new files to selection const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedRemoveIds.includes(id)); + const newSelectedFileIds = [...validSelectedFileIds, ...addedIds]; return { ...state, @@ -62,7 +63,7 @@ function processFileSwap( }, ui: { ...state.ui, - selectedFileIds: validSelectedFileIds + selectedFileIds: newSelectedFileIds } }; } diff --git a/frontend/src/hooks/tools/sign/useSignParameters.ts b/frontend/src/hooks/tools/sign/useSignParameters.ts index c96a31103..bc379a36d 100644 --- a/frontend/src/hooks/tools/sign/useSignParameters.ts +++ b/frontend/src/hooks/tools/sign/useSignParameters.ts @@ -17,6 +17,7 @@ export interface SignParameters { signerName?: string; fontFamily?: string; fontSize?: number; + textColor?: string; } export const DEFAULT_PARAMETERS: SignParameters = { @@ -26,6 +27,7 @@ export const DEFAULT_PARAMETERS: SignParameters = { signerName: '', fontFamily: 'Helvetica', fontSize: 16, + textColor: '#000000', }; const validateSignParameters = (parameters: SignParameters): boolean => { diff --git a/frontend/src/tools/Sign.tsx b/frontend/src/tools/Sign.tsx index 807dd732d..f76621895 100644 --- a/frontend/src/tools/Sign.tsx +++ b/frontend/src/tools/Sign.tsx @@ -18,6 +18,7 @@ const Sign = (props: BaseToolProps) => { const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature(); const { consumeFiles, selectors } = useFileContext(); const { exportActions, getScrollState } = useViewer(); + const { setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation(); // Track which signature mode was active for reactivation after save const activeModeRef = useRef<'draw' | 'placement' | null>(null); @@ -38,6 +39,11 @@ const Sign = (props: BaseToolProps) => { handleSignaturePlacement(); }, [handleSignaturePlacement]); + const handleDeactivateSignature = useCallback(() => { + activeModeRef.current = null; + deactivateDrawMode(); + }, [deactivateDrawMode]); + const base = useBaseTool( 'sign', useSignParameters, @@ -45,14 +51,18 @@ const Sign = (props: BaseToolProps) => { props ); - // Open viewer when files are selected + const hasOpenedViewer = useRef(false); + + // Open viewer when files are selected (only once) useEffect(() => { - if (base.selectedFiles.length > 0) { + if (base.selectedFiles.length > 0 && !hasOpenedViewer.current) { setWorkbench('viewer'); + hasOpenedViewer.current = true; } }, [base.selectedFiles.length, setWorkbench]); + // Sync signature configuration with context useEffect(() => { setSignatureConfig(base.params.parameters); @@ -61,6 +71,10 @@ const Sign = (props: BaseToolProps) => { // Save signed files to the system - apply signatures using EmbedPDF and replace original const handleSaveToSystem = useCallback(async () => { try { + // Unregister unsaved changes checker to prevent warning during apply + unregisterUnsavedChangesChecker(); + setHasUnsavedChanges(false); + // Get the original file let originalFile = null; if (base.selectedFiles.length > 0) { @@ -81,68 +95,63 @@ const Sign = (props: BaseToolProps) => { } // Use the signature flattening utility - const success = await flattenSignatures({ + const flattenResult = await flattenSignatures({ signatureApiRef, getImageData, exportActions, selectors, - consumeFiles, originalFile, getScrollState }); - if (success) { - console.log('✓ Signature flattening completed successfully'); + if (flattenResult) { + // Now consume the files - this triggers the viewer reload + await consumeFiles( + flattenResult.inputFileIds, + [flattenResult.outputStirlingFile], + [flattenResult.outputStub] + ); // Mark signatures as applied setSignaturesApplied(true); - // Force refresh the viewer to show the flattened PDF - setTimeout(() => { - // Navigate away from viewer and back to force reload - setWorkbench('fileEditor'); - setTimeout(() => { - setWorkbench('viewer'); + // Deactivate signature placement mode after everything completes + handleDeactivateSignature(); - // Reactivate the signature mode that was active before save - if (activeModeRef.current === 'draw') { - activateDrawMode(); - } else if (activeModeRef.current === 'placement') { - handleSignaturePlacement(); - } - }, 100); - }, 200); + // File has been consumed - viewer should reload automatically via key prop } else { console.error('Signature flattening failed'); } } catch (error) { console.error('Error saving signed document:', error); } - }, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode]); + }, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker]); const getSteps = () => { const steps = []; - // Step 1: Signature Configuration - Always visible - steps.push({ - title: t('sign.steps.configure', 'Configure Signature'), - isCollapsed: false, - onCollapsedClick: undefined, - content: ( - - ), - }); + // Step 1: Signature Configuration - Only visible when file is loaded + if (base.selectedFiles.length > 0) { + steps.push({ + title: t('sign.steps.configure', 'Configure Signature'), + isCollapsed: false, + onCollapsedClick: undefined, + content: ( + + ), + }); + } return steps; }; diff --git a/frontend/src/utils/signatureFlattening.ts b/frontend/src/utils/signatureFlattening.ts index fd7e701a6..1ff343155 100644 --- a/frontend/src/utils/signatureFlattening.ts +++ b/frontend/src/utils/signatureFlattening.ts @@ -1,7 +1,7 @@ import { PDFDocument, rgb } from 'pdf-lib'; import { generateThumbnailWithMetadata } from './thumbnailUtils'; -import { createProcessedFile } from '../contexts/file/fileActions'; -import { createNewStirlingFileStub, createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext'; +import { createProcessedFile, createChildStub } from '../contexts/file/fileActions'; +import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '../types/fileContext'; import type { SignatureAPI } from '../components/viewer/SignatureAPIBridge'; interface MinimalFileContextSelectors { @@ -17,13 +17,18 @@ interface SignatureFlatteningOptions { saveAsCopy: () => Promise; }; selectors: MinimalFileContextSelectors; - consumeFiles: (inputFileIds: FileId[], outputStirlingFiles: StirlingFile[], outputStirlingFileStubs: StirlingFileStub[]) => Promise; originalFile?: StirlingFile; getScrollState: () => { currentPage: number; totalPages: number }; } -export async function flattenSignatures(options: SignatureFlatteningOptions): Promise { - const { signatureApiRef, getImageData, exportActions, selectors, consumeFiles, originalFile, getScrollState } = options; +export interface SignatureFlatteningResult { + inputFileIds: FileId[]; + outputStirlingFile: StirlingFile; + outputStub: StirlingFileStub; +} + +export async function flattenSignatures(options: SignatureFlatteningOptions): Promise { + const { signatureApiRef, getImageData, exportActions, selectors, originalFile, getScrollState } = options; try { // Step 1: Extract all annotations from EmbedPDF before export @@ -66,8 +71,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr } } - console.log(`Total annotations found: ${allAnnotations.reduce((sum, page) => sum + page.annotations.length, 0)}`); - // Step 2: Delete ONLY session annotations from EmbedPDF before export (they'll be rendered manually) // Leave old annotations alone - they will remain as annotations in the PDF if (allAnnotations.length > 0 && signatureApiRef?.current) { @@ -85,7 +88,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr // Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations) if (!exportActions) { console.error('No export actions available'); - return false; + return null; } const pdfArrayBuffer = await exportActions.saveAsCopy(); @@ -111,7 +114,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr if (!currentFile) { console.error('No file available to replace'); - return false; + return null; } let signedFile = new File([blob], currentFile.name, { type: 'application/pdf' }); @@ -119,7 +122,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr // Step 4: Manually render extracted annotations onto the PDF using PDF-lib if (allAnnotations.length > 0) { try { - console.log('Manually rendering annotations onto PDF...'); const pdfArrayBufferForFlattening = await signedFile.arrayBuffer(); // Try different loading options to handle problematic PDFs @@ -150,7 +152,6 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr const pages = pdfDoc.getPages(); - for (const pageData of allAnnotations) { const { pageIndex, annotations } = pageData; @@ -189,6 +190,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr if (imageDataUrl && typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image')) { try { + // Convert data URL to bytes const base64Data = imageDataUrl.split(',')[1]; const imageBytes = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)); @@ -215,6 +217,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr console.error('Failed to render image annotation:', imageError); } } else if (annotation.content || annotation.text) { + console.warn('Rendering text annotation instead'); // Handle text annotations page.drawText(annotation.content || annotation.text, { x: pdfX, @@ -287,23 +290,30 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr const record = selectors.getStirlingFileStub(currentFile.fileId); if (!record) { console.error('No file record found for:', currentFile.fileId); - return false; + return null; } - // Create output stub and file - const outputStub = createNewStirlingFileStub(signedFile, undefined, thumbnailResult.thumbnail, processedFileMetadata); + // Create output stub and file as a child of the original (increments version) + const outputStub = createChildStub( + record, + { toolId: 'sign', timestamp: Date.now() }, + signedFile, + thumbnailResult.thumbnail, + processedFileMetadata + ); const outputStirlingFile = createStirlingFile(signedFile, outputStub.id); - // Replace the original file with the signed version - await consumeFiles(inputFileIds, [outputStirlingFile], [outputStub]); - - console.log('✓ Signature flattening completed successfully'); - return true; + // Return the flattened file data for consumption by caller + return { + inputFileIds, + outputStirlingFile, + outputStub + }; } - return false; + return null; } catch (error) { console.error('Error flattening signatures:', error); - return false; + return null; } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..9ae7c67c8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Stirling-PDF", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}