Merge branch 'V2' into feature/v2/selected-pageeditor

This commit is contained in:
Reece Browne
2025-10-20 15:52:23 +01:00
committed by GitHub
33 changed files with 2381 additions and 91 deletions

View File

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

View File

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

View File

@@ -23,7 +23,7 @@ const DesktopLayout: React.FC = () => {
width: '13.625rem',
flexShrink: 0,
height: '100%',
}}>
}} data-tour="file-sources">
<FileSourceButtons />
</Grid.Col>

View 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));
}

View 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>
</>
);
}

View 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>
);
}

View File

@@ -155,4 +155,4 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
);
};
export default AppConfigModal;
export default AppConfigModal;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -145,6 +145,7 @@
.content-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 1rem 0;
}
/* Spacer */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
/>
)}

View File

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

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

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

View File

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

View File

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