mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-19 02:22:11 +01:00
Merge branch 'V2' into feature/v2/selected-pageeditor
This commit is contained in:
@@ -7,8 +7,11 @@ import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "./contexts/PreferencesContext";
|
||||
import { OnboardingProvider } from "./contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
import OnboardingTour from "./components/onboarding/OnboardingTour";
|
||||
|
||||
// Import global styles
|
||||
import "./styles/tailwind.css";
|
||||
@@ -43,25 +46,30 @@ export default function App() {
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
<OnboardingProvider>
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</FileContextProvider>
|
||||
</OnboardingProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
|
||||
@@ -247,6 +247,7 @@ const FileEditorThumbnail = ({
|
||||
ref={fileElementRef}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
data-tour="file-card-checkbox"
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
@@ -293,11 +294,12 @@ const FileEditorThumbnail = ({
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
|
||||
<ActionIcon
|
||||
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
|
||||
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
|
||||
variant="subtle"
|
||||
className={isPinned ? styles.pinned : styles.headerIconButton}
|
||||
data-tour="file-card-pin"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (actualFile) {
|
||||
|
||||
@@ -23,7 +23,7 @@ const DesktopLayout: React.FC = () => {
|
||||
width: '13.625rem',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
}}>
|
||||
}} data-tour="file-sources">
|
||||
<FileSourceButtons />
|
||||
</Grid.Col>
|
||||
|
||||
|
||||
8
frontend/src/components/onboarding/OnboardingTour.css
Normal file
8
frontend/src/components/onboarding/OnboardingTour.css
Normal file
@@ -0,0 +1,8 @@
|
||||
/* Glow effect for tour highlighted area */
|
||||
.tour-highlight-glow {
|
||||
stroke: var(--mantine-primary-color-filled);
|
||||
stroke-width: 3px;
|
||||
rx: 8px;
|
||||
ry: 8px;
|
||||
filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled));
|
||||
}
|
||||
331
frontend/src/components/onboarding/OnboardingTour.tsx
Normal file
331
frontend/src/components/onboarding/OnboardingTour.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import React from "react";
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { useTourOrchestration } from '../../contexts/TourOrchestrationContext';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import TourWelcomeModal from './TourWelcomeModal';
|
||||
import './OnboardingTour.css';
|
||||
|
||||
// Enum case order defines order steps will appear
|
||||
enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
// Sync tour open state with context and reset to step 0 when reopening
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
// Tour is being opened (Help button pressed), reset to first step
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
} = useTourOrchestration();
|
||||
|
||||
// Define steps as object keyed by enum - TypeScript ensures all keys are present
|
||||
const stepsConfig: Record<TourStep, StepType> = {
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[TourStep.SELECT_CROP_TOOL]: {
|
||||
selector: '[data-tour="tool-button-crop"]',
|
||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => selectCropTool(),
|
||||
},
|
||||
[TourStep.TOOL_INTERFACE]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILES_BUTTON]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
}
|
||||
},
|
||||
[TourStep.WORKBENCH]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToActiveFiles(),
|
||||
},
|
||||
[TourStep.FILE_CHECKBOX]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => modifyCropSettings(),
|
||||
},
|
||||
[TourStep.RUN_BUTTON]: {
|
||||
selector: '[data-tour="run-button"]',
|
||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
actionAfter: () => executeTool(),
|
||||
},
|
||||
[TourStep.RESULTS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILE_REPLACEMENT]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.PIN_BUTTON]: {
|
||||
selector: '[data-tour="file-card-pin"]',
|
||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
|
||||
// Convert to array using enum's numeric ordering
|
||||
const steps = Object.values(stepsConfig);
|
||||
|
||||
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
setIsOpen: (value: boolean) => void;
|
||||
}) => {
|
||||
if (steps && currentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
restoreWorkbenchState();
|
||||
completeTour();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
||||
setIsOpen(false);
|
||||
restoreWorkbenchState();
|
||||
completeTour();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TourWelcomeModal
|
||||
opened={showWelcomeModal}
|
||||
onStartTour={() => {
|
||||
setShowWelcomeModal(false);
|
||||
startTour();
|
||||
}}
|
||||
onMaybeLater={() => {
|
||||
setShowWelcomeModal(false);
|
||||
}}
|
||||
onDontShowAgain={() => {
|
||||
setShowWelcomeModal(false);
|
||||
completeTour();
|
||||
}}
|
||||
/>
|
||||
<TourProvider
|
||||
steps={steps}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
e.stopPropagation();
|
||||
advanceTour(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
// Handle right arrow key to advance tour
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
advanceTour(clickProps);
|
||||
}
|
||||
// Handle escape key to close tour
|
||||
else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
handleCloseTour(clickProps);
|
||||
}
|
||||
}}
|
||||
styles={{
|
||||
popover: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
maxWidth: '400px',
|
||||
}),
|
||||
maskArea: (base) => ({
|
||||
...base,
|
||||
rx: 8,
|
||||
}),
|
||||
badge: (base) => ({
|
||||
...base,
|
||||
backgroundColor: 'var(--mantine-primary-color-filled)',
|
||||
}),
|
||||
controls: (base) => ({
|
||||
...base,
|
||||
justifyContent: 'center',
|
||||
}),
|
||||
}}
|
||||
highlightedMaskClassName="tour-highlight-glow"
|
||||
showNavigation={true}
|
||||
showBadge={false}
|
||||
showCloseButton={true}
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={true}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
const isLast = currentStep === stepsLength - 1;
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => {
|
||||
advanceTour({ setCurrentStep, currentStep, steps, setIsOpen });
|
||||
}}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowForwardIcon />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
style={{ position: 'absolute', top: '8px', right: '8px' }}
|
||||
/>
|
||||
),
|
||||
Content: ({ content } : {content: string}) => (
|
||||
<div
|
||||
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent />
|
||||
</TourProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
frontend/src/components/onboarding/TourWelcomeModal.tsx
Normal file
82
frontend/src/components/onboarding/TourWelcomeModal.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
|
||||
|
||||
interface TourWelcomeModalProps {
|
||||
opened: boolean;
|
||||
onStartTour: () => void;
|
||||
onMaybeLater: () => void;
|
||||
onDontShowAgain: () => void;
|
||||
}
|
||||
|
||||
export default function TourWelcomeModal({
|
||||
opened,
|
||||
onStartTour,
|
||||
onMaybeLater,
|
||||
onDontShowAgain,
|
||||
}: TourWelcomeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onMaybeLater}
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Title order={2}>
|
||||
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
|
||||
</Title>
|
||||
<Text size="md" c="dimmed">
|
||||
{t('onboarding.welcomeModal.description',
|
||||
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
|
||||
)}
|
||||
</Text>
|
||||
<Text
|
||||
size="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('onboarding.welcomeModal.helpHint',
|
||||
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
onClick={onStartTour}
|
||||
size="md"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
>
|
||||
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
|
||||
</Button>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
onClick={onMaybeLater}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={onDontShowAgain}
|
||||
size="md"
|
||||
variant="light"
|
||||
>
|
||||
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -155,4 +155,4 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AppConfigModal;
|
||||
export default AppConfigModal;
|
||||
|
||||
@@ -51,6 +51,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onSelect}
|
||||
data-testid="file-card"
|
||||
data-tour="file-card-checkbox"
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Box
|
||||
|
||||
@@ -14,6 +14,7 @@ import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import { useOnboarding } from '../../contexts/OnboardingContext';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -27,6 +28,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
const { config } = useAppConfig();
|
||||
const { startTour } = useOnboarding();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [activeButton, setActiveButton] = useState<string>('tools');
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
@@ -60,7 +62,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
|
||||
// Render navigation button with conditional URL support
|
||||
return (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
|
||||
<div
|
||||
key={config.id}
|
||||
className="flex flex-col items-center gap-1"
|
||||
style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}
|
||||
data-tour={`${config.id}-button`}
|
||||
>
|
||||
<ActionIcon
|
||||
{...(navProps ? {
|
||||
component: "a" as const,
|
||||
@@ -88,8 +95,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const buttonConfigs: ButtonConfig[] = [
|
||||
const mainButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
name: t("quickAccess.read", "Read"),
|
||||
@@ -131,6 +137,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const middleButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'files',
|
||||
name: t("quickAccess.files", "Files"),
|
||||
@@ -150,6 +159,20 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
];
|
||||
|
||||
const bottomButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'help',
|
||||
name: t("quickAccess.help", "Help"),
|
||||
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
type: 'action',
|
||||
onClick: () => {
|
||||
startTour();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
@@ -162,8 +185,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -198,49 +219,41 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}}
|
||||
>
|
||||
<div className="scrollable-content">
|
||||
{/* Top section with main buttons */}
|
||||
{/* Main navigation section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{buttonConfigs.slice(0, -1).map((config, index) => (
|
||||
{mainButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
|
||||
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
|
||||
{index === 1 && (
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Spacer to push Config button to bottom */}
|
||||
{/* Divider after main buttons */}
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
|
||||
{/* Middle section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{middleButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Spacer to push bottom buttons to bottom */}
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||
<ActionIcon
|
||||
size={config.size || 'lg'}
|
||||
variant="subtle"
|
||||
onClick={config.onClick}
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
|
||||
aria-label={config.name}
|
||||
data-testid={`${config.id}-button`}
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{config.icon}
|
||||
</span>
|
||||
</ActionIcon>
|
||||
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
{/* Bottom section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{bottomButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function RightRail() {
|
||||
<div className="right-rail-inner">
|
||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
|
||||
<React.Fragment key={section}>
|
||||
<div className="right-rail-section">
|
||||
<div className="right-rail-section" data-tour="right-rail-controls">
|
||||
{sectionButtons.map((btn, index) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
@@ -186,7 +186,7 @@ export default function RightRail() {
|
||||
<Divider className="right-rail-divider" />
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }} data-tour="right-rail-settings">
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
|
||||
@@ -346,7 +346,9 @@ const TopControls = ({
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<SegmentedControl
|
||||
data={viewOptions}
|
||||
data-tour="view-switcher"
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, customViews)}
|
||||
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface ConfigColors {
|
||||
|
||||
export const createConfigNavSections = (
|
||||
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||
onLogoutClick: () => void
|
||||
onLogoutClick: () => void,
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
@@ -61,4 +61,4 @@ export const createConfigNavSections = (
|
||||
];
|
||||
|
||||
return sections;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
.content-divider {
|
||||
width: 3.75rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Spacer */
|
||||
|
||||
@@ -94,6 +94,7 @@ const FullscreenToolSurface = ({
|
||||
style={style}
|
||||
role="region"
|
||||
aria-label={t('toolPanel.fullscreen.heading', 'All tools (fullscreen view)')}
|
||||
data-tour="tool-panel"
|
||||
>
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function ToolPanel() {
|
||||
<div
|
||||
ref={toolPanelRef}
|
||||
data-sidebar="tool-panel"
|
||||
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
|
||||
className={`tool-panel flex flex-col ${fullscreenExpanded ? 'tool-panel--fullscreen-active' : 'overflow-hidden'} bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
|
||||
isRainbowMode ? rainbowStyles.rainbowPaper : ''
|
||||
} ${isMobile ? 'h-full border-r-0' : 'h-screen'} ${fullscreenExpanded ? 'tool-panel--fullscreen' : ''}`}
|
||||
@@ -135,7 +136,7 @@ export default function ToolPanel() {
|
||||
mode="filter"
|
||||
/>
|
||||
{!isMobile && leftPanelView === 'toolPicker' && (
|
||||
<Tooltip
|
||||
<Tooltip
|
||||
content={toggleLabel}
|
||||
position="bottom"
|
||||
arrow={true}
|
||||
|
||||
@@ -172,7 +172,8 @@ const CropAreaSelector: React.FC<CropAreaSelectorProps> = ({
|
||||
border: `2px solid ${theme.other.crop.overlayBorder}`,
|
||||
backgroundColor: theme.other.crop.overlayBackground,
|
||||
cursor: 'move',
|
||||
pointerEvents: 'auto'
|
||||
pointerEvents: 'auto',
|
||||
transition: (isDragging || isResizing) ? undefined : 'all 1s ease-in-out'
|
||||
}}
|
||||
onMouseDown={handleOverlayMouseDown}
|
||||
>
|
||||
|
||||
@@ -93,6 +93,19 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
|
||||
loadPDFDimensions();
|
||||
}, [selectedStub, selectedFile, parameters]);
|
||||
|
||||
// Listen for tour events to set crop area
|
||||
useEffect(() => {
|
||||
const handleSetCropArea = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<Rectangle>;
|
||||
if (customEvent.detail && pdfBounds) {
|
||||
parameters.setCropArea(customEvent.detail, pdfBounds);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('tour:setCropArea', handleSetCropArea);
|
||||
return () => window.removeEventListener('tour:setCropArea', handleSetCropArea);
|
||||
}, [parameters, pdfBounds]);
|
||||
|
||||
// Current crop area
|
||||
const cropArea = parameters.getCropArea();
|
||||
|
||||
@@ -137,7 +150,7 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
|
||||
const isFullCrop = parameters.isFullPDFCrop(pdfBounds);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="md" data-tour="crop-settings">
|
||||
{/* PDF Preview with Crop Selector */}
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center">
|
||||
|
||||
@@ -42,6 +42,7 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
|
||||
onClick={onClick}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
data-tour={`tool-button-${id}`}
|
||||
>
|
||||
{tool.icon ? (
|
||||
<span
|
||||
|
||||
@@ -41,6 +41,7 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
onClick={onClick}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
data-tour={`tool-button-${id}`}
|
||||
>
|
||||
{tool.icon ? (
|
||||
<span
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface OperationButtonProps {
|
||||
mt?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
'data-testid'?: string;
|
||||
'data-tour'?: string;
|
||||
}
|
||||
|
||||
const OperationButton = ({
|
||||
@@ -26,7 +27,8 @@ const OperationButton = ({
|
||||
fullWidth = false,
|
||||
mt = 'md',
|
||||
type = 'button',
|
||||
'data-testid': dataTestId
|
||||
'data-testid': dataTestId,
|
||||
'data-tour': dataTour
|
||||
}: OperationButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,6 +45,7 @@ const OperationButton = ({
|
||||
variant={variant}
|
||||
color={color}
|
||||
data-testid={dataTestId}
|
||||
data-tour={dataTour}
|
||||
style={{ minHeight: '2.5rem' }}
|
||||
>
|
||||
{isLoading
|
||||
|
||||
@@ -105,6 +105,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
loadingText={config.executeButton.loadingText}
|
||||
submitText={config.executeButton.text}
|
||||
data-testid={config.executeButton.testId}
|
||||
data-tour="run-button"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -112,10 +112,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
data-tour={`tool-button-${id}`}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: 'visible'
|
||||
},
|
||||
label: { overflow: 'visible' }
|
||||
@@ -137,10 +138,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
data-tour={`tool-button-${id}`}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: 'visible'
|
||||
},
|
||||
label: { overflow: 'visible' }
|
||||
@@ -159,14 +161,15 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
aria-disabled={isUnavailable}
|
||||
data-tour={`tool-button-${id}`}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
cursor: isUnavailable ? 'not-allowed' : undefined,
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
cursor: isUnavailable ? 'not-allowed' : undefined,
|
||||
overflow: 'visible'
|
||||
},
|
||||
label: { overflow: 'visible' }
|
||||
},
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
{buttonContent}
|
||||
|
||||
80
frontend/src/contexts/OnboardingContext.tsx
Normal file
80
frontend/src/contexts/OnboardingContext.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { usePreferences } from './PreferencesContext';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
|
||||
interface OnboardingContextValue {
|
||||
isOpen: boolean;
|
||||
currentStep: number;
|
||||
setCurrentStep: (step: number) => void;
|
||||
startTour: () => void;
|
||||
closeTour: () => void;
|
||||
completeTour: () => void;
|
||||
resetTour: () => void;
|
||||
showWelcomeModal: boolean;
|
||||
setShowWelcomeModal: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
|
||||
|
||||
export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [showWelcomeModal, setShowWelcomeModal] = useState(false);
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
// Auto-show welcome modal for first-time users after preferences load
|
||||
// Only show after user has seen the tool panel mode prompt
|
||||
// Also, don't show tour on mobile devices because it feels clunky
|
||||
useEffect(() => {
|
||||
if (!preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) {
|
||||
setShowWelcomeModal(true);
|
||||
}
|
||||
}, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile]);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
setCurrentStep(0);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeTour = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
const completeTour = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
}, [updatePreference]);
|
||||
|
||||
const resetTour = useCallback(() => {
|
||||
updatePreference('hasCompletedOnboarding', false);
|
||||
setCurrentStep(0);
|
||||
setIsOpen(true);
|
||||
}, [updatePreference]);
|
||||
|
||||
return (
|
||||
<OnboardingContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
currentStep,
|
||||
setCurrentStep,
|
||||
startTour,
|
||||
closeTour,
|
||||
completeTour,
|
||||
resetTour,
|
||||
showWelcomeModal,
|
||||
setShowWelcomeModal,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</OnboardingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useOnboarding = (): OnboardingContextValue => {
|
||||
const context = useContext(OnboardingContext);
|
||||
if (!context) {
|
||||
throw new Error('useOnboarding must be used within an OnboardingProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
207
frontend/src/contexts/TourOrchestrationContext.tsx
Normal file
207
frontend/src/contexts/TourOrchestrationContext.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||
import { useFileHandler } from '../hooks/useFileHandler';
|
||||
import { useFilesModalContext } from './FilesModalContext';
|
||||
import { useNavigationActions } from './NavigationContext';
|
||||
import { useToolWorkflow } from './ToolWorkflowContext';
|
||||
import { useAllFiles, useFileManagement } from './FileContext';
|
||||
import { StirlingFile } from '../types/fileContext';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
|
||||
interface TourOrchestrationContextType {
|
||||
// State management
|
||||
saveWorkbenchState: () => void;
|
||||
restoreWorkbenchState: () => Promise<void>;
|
||||
|
||||
// Tool deselection
|
||||
backToAllTools: () => void;
|
||||
|
||||
// Tool selection
|
||||
selectCropTool: () => void;
|
||||
|
||||
// File operations
|
||||
loadSampleFile: () => Promise<void>;
|
||||
|
||||
// View switching
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
|
||||
// File operations
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
|
||||
// Crop settings (placeholder for now)
|
||||
modifyCropSettings: () => void;
|
||||
|
||||
// Tool execution
|
||||
executeTool: () => void;
|
||||
}
|
||||
|
||||
const TourOrchestrationContext = createContext<TourOrchestrationContextType | undefined>(undefined);
|
||||
|
||||
export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { addFiles } = useFileHandler();
|
||||
const { closeFilesModal } = useFilesModalContext();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const { handleToolSelect, handleBackToTools } = useToolWorkflow();
|
||||
const { files } = useAllFiles();
|
||||
const { clearAllFiles } = useFileManagement();
|
||||
|
||||
// Store the user's files before tour starts
|
||||
const savedFilesRef = useRef<StirlingFile[]>([]);
|
||||
|
||||
// Keep a ref to always have the latest files
|
||||
const filesRef = useRef<StirlingFile[]>(files);
|
||||
React.useEffect(() => {
|
||||
filesRef.current = files;
|
||||
}, [files]);
|
||||
|
||||
const saveWorkbenchState = useCallback(() => {
|
||||
// Get fresh files from ref
|
||||
const currentFiles = filesRef.current;
|
||||
console.log('Saving workbench state, files count:', currentFiles.length);
|
||||
savedFilesRef.current = [...currentFiles];
|
||||
// Clear all files for clean demo
|
||||
clearAllFiles();
|
||||
}, [clearAllFiles]);
|
||||
|
||||
const restoreWorkbenchState = useCallback(async () => {
|
||||
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
|
||||
|
||||
// Go back to All Tools
|
||||
handleBackToTools();
|
||||
|
||||
// Clear all files (including tour sample)
|
||||
clearAllFiles();
|
||||
|
||||
// Delete all active files from storage (they're just the ones from the tour)
|
||||
const currentFiles = filesRef.current;
|
||||
if (currentFiles.length > 0) {
|
||||
try {
|
||||
await Promise.all(currentFiles.map(file => fileStorage.deleteStirlingFile(file.fileId)));
|
||||
console.log(`Deleted ${currentFiles.length} file(s) from storage`);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete files from storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore saved files
|
||||
if (savedFilesRef.current.length > 0) {
|
||||
// Create fresh File objects from StirlingFile to avoid ID conflicts
|
||||
const filesToRestore = await Promise.all(
|
||||
savedFilesRef.current.map(async (sf) => {
|
||||
const buffer = await sf.arrayBuffer();
|
||||
return new File([buffer], sf.name, { type: sf.type, lastModified: sf.lastModified });
|
||||
})
|
||||
);
|
||||
console.log('Restoring files:', filesToRestore.map(f => f.name));
|
||||
await addFiles(filesToRestore);
|
||||
savedFilesRef.current = [];
|
||||
}
|
||||
}, [clearAllFiles, addFiles, handleBackToTools]);
|
||||
|
||||
const backToAllTools = useCallback(() => {
|
||||
handleBackToTools();
|
||||
}, [handleBackToTools]);
|
||||
|
||||
const selectCropTool = useCallback(() => {
|
||||
handleToolSelect('crop');
|
||||
}, [handleToolSelect]);
|
||||
|
||||
const loadSampleFile = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/samples/Sample.pdf');
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' });
|
||||
|
||||
await addFiles([file]);
|
||||
closeFilesModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to load sample file:', error);
|
||||
}
|
||||
}, [addFiles, closeFilesModal]);
|
||||
|
||||
const switchToViewer = useCallback(() => {
|
||||
navActions.setWorkbench('viewer');
|
||||
}, [navActions]);
|
||||
|
||||
const switchToPageEditor = useCallback(() => {
|
||||
navActions.setWorkbench('pageEditor');
|
||||
}, [navActions]);
|
||||
|
||||
const switchToActiveFiles = useCallback(() => {
|
||||
navActions.setWorkbench('fileEditor');
|
||||
}, [navActions]);
|
||||
|
||||
const selectFirstFile = useCallback(() => {
|
||||
// File selection is handled by FileCard onClick
|
||||
// This function could trigger a click event on the first file card
|
||||
const firstFileCard = document.querySelector('[data-tour="file-card-checkbox"]') as HTMLElement;
|
||||
if (firstFileCard) {
|
||||
// Check if already selected (data-selected attribute)
|
||||
const isSelected = firstFileCard.getAttribute('data-selected') === 'true';
|
||||
// Only click if not already selected (to avoid toggling off)
|
||||
if (!isSelected) {
|
||||
firstFileCard.click();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pinFile = useCallback(() => {
|
||||
// Click the pin button directly
|
||||
const pinButton = document.querySelector('[data-tour="file-card-pin"]') as HTMLElement;
|
||||
if (pinButton) {
|
||||
pinButton.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const modifyCropSettings = useCallback(() => {
|
||||
// Dispatch a custom event to modify crop settings
|
||||
const event = new CustomEvent('tour:setCropArea', {
|
||||
detail: {
|
||||
x: 80,
|
||||
y: 435,
|
||||
width: 440,
|
||||
height: 170,
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}, []);
|
||||
|
||||
const executeTool = useCallback(() => {
|
||||
// Trigger the run button click
|
||||
const runButton = document.querySelector('[data-tour="run-button"]') as HTMLElement;
|
||||
if (runButton) {
|
||||
runButton.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: TourOrchestrationContextType = {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
};
|
||||
|
||||
return (
|
||||
<TourOrchestrationContext.Provider value={value}>
|
||||
{children}
|
||||
</TourOrchestrationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTourOrchestration = (): TourOrchestrationContextType => {
|
||||
const context = useContext(TourOrchestrationContext);
|
||||
if (!context) {
|
||||
throw new Error('useTourOrchestration must be used within TourOrchestrationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ export interface UserPreferences {
|
||||
theme: ThemeMode;
|
||||
toolPanelModePromptSeen: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -17,6 +18,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
theme: getSystemTheme(),
|
||||
toolPanelModePromptSeen: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
@@ -28,7 +28,7 @@ const Crop = (props: BaseToolProps) => {
|
||||
steps: [
|
||||
{
|
||||
title: t("crop.steps.selectArea", "Select Crop Area"),
|
||||
isCollapsed: !base.hasFiles, // Collapsed until files selected
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined,
|
||||
tooltip: tooltips,
|
||||
content: (
|
||||
|
||||
Reference in New Issue
Block a user