diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 9d53d472a..e94fba47d 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -2176,7 +2176,8 @@
"overrideY": "Override Y Coordinate",
"customMargin": "Custom Margin",
"customColor": "Custom Text Colour",
- "submit": "Submit"
+ "submit": "Submit",
+ "noStampSelected": "No stamp selected. Return to Step 3."
},
"removeImagePdf": {
"tags": "Remove Image,Page operations,Back end,server side"
diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json
index a58268664..ba95d6c77 100644
--- a/frontend/public/locales/en-US/translation.json
+++ b/frontend/public/locales/en-US/translation.json
@@ -1467,7 +1467,8 @@
"overrideY": "Override Y Coordinate",
"customMargin": "Custom Margin",
"customColor": "Custom Text Color",
- "submit": "Submit"
+ "submit": "Submit",
+ "noStampSelected": "No stamp selected. Return to Step 3."
},
"removeImagePdf": {
"tags": "Remove Image,Page operations,Back end,server side"
diff --git a/frontend/src/components/shared/ObscuredOverlay.tsx b/frontend/src/components/shared/ObscuredOverlay.tsx
new file mode 100644
index 000000000..0e918151f
--- /dev/null
+++ b/frontend/src/components/shared/ObscuredOverlay.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import styles from './ObscuredOverlay/ObscuredOverlay.module.css';
+
+type ObscuredOverlayProps = {
+ obscured: boolean;
+ overlayMessage?: React.ReactNode;
+ buttonText?: string;
+ onButtonClick?: () => void;
+ children: React.ReactNode;
+ // Optional border radius for the overlay container. If undefined, no radius is applied.
+ borderRadius?: string | number;
+};
+
+export default function ObscuredOverlay({
+ obscured,
+ overlayMessage,
+ buttonText,
+ onButtonClick,
+ children,
+ borderRadius,
+}: ObscuredOverlayProps) {
+ return (
+
+ {children}
+ {obscured && (
+
+
+ {overlayMessage && (
+
+ {overlayMessage}
+
+ )}
+ {buttonText && onButtonClick && (
+
+ )}
+
+
+ )}
+
+ );
+}
+
+
diff --git a/frontend/src/components/shared/ObscuredOverlay/ObscuredOverlay.module.css b/frontend/src/components/shared/ObscuredOverlay/ObscuredOverlay.module.css
new file mode 100644
index 000000000..5651993c4
--- /dev/null
+++ b/frontend/src/components/shared/ObscuredOverlay/ObscuredOverlay.module.css
@@ -0,0 +1,41 @@
+.container {
+ position: relative;
+}
+
+.overlay {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 16px;
+ color: #ffffff;
+ font-weight: 600;
+ background: rgba(16, 18, 27, 0.55);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ z-index: 2;
+}
+
+.overlayContent {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ align-items: center;
+}
+
+.overlayMessage {
+ color: #ffffff;
+ font-weight: 600;
+}
+
+.overlayButton {
+ padding: 8px 12px;
+ border-radius: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.06);
+ color: #ffffff;
+ cursor: pointer;
+}
diff --git a/frontend/src/components/tools/addStamp/StampPreview.tsx b/frontend/src/components/tools/addStamp/StampPreview.tsx
index 62eed20b4..66ad3c2f0 100644
--- a/frontend/src/components/tools/addStamp/StampPreview.tsx
+++ b/frontend/src/components/tools/addStamp/StampPreview.tsx
@@ -3,7 +3,6 @@ import { AddStampParameters } from './useAddStampParameters';
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
import { A4_ASPECT_RATIO, getFirstSelectedPage, getFontFamily, computeStampPreviewStyle } from './StampPreviewUtils';
-import FitText from '../../shared/FitText';
import styles from './StampPreview.module.css';
type Props = {
@@ -87,7 +86,7 @@ export default function StampPreview({ parameters, onParameterChange, file, show
}
try {
const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pageNumbers));
- const pageId = `${file.name}:page:${pageNumber}`;
+ const pageId = `${file.name}:${file.size}:${file.lastModified}:page:${pageNumber}`;
const thumb = await requestThumbnail(pageId, file, pageNumber);
if (isActive) setPageThumbnail(thumb || null);
} catch {
@@ -277,18 +276,17 @@ export default function StampPreview({ parameters, onParameterChange, file, show
style={style.item as React.CSSProperties}
>
{(parameters.stampText || '').split('\n').map((line, idx) => (
-
+ >
+ {line || '\u00A0'}
+
))}
{itemHandles}
diff --git a/frontend/src/components/tools/addStamp/StampPreviewUtils.ts b/frontend/src/components/tools/addStamp/StampPreviewUtils.ts
index 17a4f1bfe..3ee7811ea 100644
--- a/frontend/src/components/tools/addStamp/StampPreviewUtils.ts
+++ b/frontend/src/components/tools/addStamp/StampPreviewUtils.ts
@@ -168,13 +168,45 @@ export function computeStampPreviewStyle(
const xPts = calcX();
const yPts = calcY();
const xPx = xPts * scaleX;
- const yPx = yPts * scaleY;
+ let yPx = yPts * scaleY;
+ // Vertical correction: text appears lower in preview vs output for middle/bottom rows
+ if (parameters.stampType === 'text') {
+ try {
+ const rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || '16') || 16;
+ const middleRowOffsetPx = 1 * rootFontSizePx;
+ const bottomRowOffsetPx = 1.25 * rootFontSizePx;
+ const rowIndex = Math.floor((position - 1) / 3);
+ if (rowIndex === 1) {
+ yPx += middleRowOffsetPx;
+ } else if (rowIndex === 2) {
+ yPx += bottomRowOffsetPx;
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ }
const widthPx = widthPtsContent * scaleX;
const heightPx = heightPtsContent * scaleY;
const opacity = Math.max(0, Math.min(1, parameters.opacity / 100));
const displayOpacity = opacity;
+ // Horizontal alignment inside the preview item for text stamps
+ let alignItems: 'flex-start' | 'center' | 'flex-end' = 'flex-start';
+ if (parameters.stampType === 'text') {
+ const colIndex = position % 3; // 1: left, 2: center, 0: right
+ switch (colIndex) {
+ case 2: // center column
+ alignItems = 'center';
+ break;
+ case 0: // right column
+ alignItems = 'flex-end';
+ break;
+ default:
+ alignItems = 'flex-start';
+ }
+ }
+
return {
container: {
position: 'relative',
@@ -198,6 +230,7 @@ export function computeStampPreviewStyle(
flexDirection: 'column',
justifyContent: 'flex-start',
lineHeight: 1,
+ alignItems,
cursor: showQuickGrid ? 'default' : 'move',
pointerEvents: showQuickGrid ? 'none' : 'auto',
}
diff --git a/frontend/src/components/tools/addStamp/useAddStampParameters.ts b/frontend/src/components/tools/addStamp/useAddStampParameters.ts
index c448b7012..f11d33d5a 100644
--- a/frontend/src/components/tools/addStamp/useAddStampParameters.ts
+++ b/frontend/src/components/tools/addStamp/useAddStampParameters.ts
@@ -15,6 +15,7 @@ export interface AddStampParameters extends BaseParameters {
customMargin: 'small' | 'medium' | 'large' | 'x-large';
customColor: string;
pageNumbers: string;
+ _activePill: 'fontSize' | 'rotation' | 'opacity';
}
export const defaultParameters: AddStampParameters = {
@@ -30,6 +31,7 @@ export const defaultParameters: AddStampParameters = {
customMargin: 'medium',
customColor: '#d3d3d3',
pageNumbers: '1',
+ _activePill: 'fontSize',
};
export type AddStampParametersHook = BaseParametersHook;
diff --git a/frontend/src/components/tools/shared/SingleExpansionContext.tsx b/frontend/src/components/tools/shared/SingleExpansionContext.tsx
deleted file mode 100644
index 770a6205f..000000000
--- a/frontend/src/components/tools/shared/SingleExpansionContext.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import React, { createContext, useContext, useState, useCallback } from 'react';
-
-// Context for managing single step expansion
-interface SingleExpansionContextType {
- expandedStep: string | null;
- setExpandedStep: (stepId: string | null) => void;
- enabled: boolean;
-}
-
-const SingleExpansionContext = createContext({
- expandedStep: null,
- setExpandedStep: (_: string | null) => {},
- enabled: false,
-});
-
-export const useSingleExpansion = () => useContext(SingleExpansionContext);
-
-// Provider component for single expansion mode
-export const SingleExpansionProvider: React.FC<{
- children: React.ReactNode;
- enabled: boolean;
- initialExpandedStep?: string | null;
-}> = ({ children, enabled, initialExpandedStep = null }) => {
- const [expandedStep, setExpandedStep] = useState(initialExpandedStep);
-
- const handleSetExpandedStep = useCallback((stepId: string | null) => {
- setExpandedStep(stepId);
- }, []);
-
- const contextValue: SingleExpansionContextType = {
- expandedStep,
- setExpandedStep: handleSetExpandedStep,
- enabled,
- };
-
- return (
-
- {children}
-
- );
-};
diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx
index 1c56da099..4724648c8 100644
--- a/frontend/src/components/tools/shared/createToolFlow.tsx
+++ b/frontend/src/components/tools/shared/createToolFlow.tsx
@@ -5,8 +5,6 @@ import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
import { StirlingFile } from '../../../types/fileContext';
-import { SingleExpansionProvider } from './SingleExpansionContext';
-import { useSingleExpandController } from './useSingleExpandController';
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
@@ -59,46 +57,38 @@ export interface ToolFlowConfig {
executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig;
forceStepNumbers?: boolean;
- maxOneExpanded?: boolean;
- initialExpandedStep?: string | null;
}
-// Hoist ToolFlowContent outside to make it stable across renders
-function ToolFlowContent({ config }: { config: ToolFlowConfig }) {
+/**
+ * Creates a flexible tool flow with configurable steps and state management left to the tool.
+ * Reduces boilerplate while allowing tools to manage their own collapse/expansion logic.
+ */
+export function createToolFlow(config: ToolFlowConfig) {
const steps = createToolSteps();
- const { onToggle, isCollapsed } = useSingleExpandController({
- filesVisible: config.files.isVisible !== false,
- stepVisibilities: config.steps.map(s => s.isVisible),
- resultsVisible: config.review.isVisible,
- });
return (
-
+
+ {/* */}
{config.title && }
{/* Files Step */}
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
- isCollapsed: isCollapsed('files', config.files.isCollapsed),
+ isCollapsed: config.files.isCollapsed,
minFiles: config.files.minFiles,
- onCollapsedClick: () => onToggle('files', config.files.onCollapsedClick)
+ onCollapsedClick: config.files.onCollapsedClick
})}
{/* Middle Steps */}
- {config.steps.map((stepConfig, index) => {
- const stepId = `step-${index}`;
- return (
-
- {steps.create(stepConfig.title, {
- isVisible: stepConfig.isVisible,
- isCollapsed: isCollapsed(stepId, stepConfig.isCollapsed),
- onCollapsedClick: () => onToggle(stepId, stepConfig.onCollapsedClick),
- tooltip: stepConfig.tooltip
- }, stepConfig.content)}
-
- );
- })}
+ {config.steps.map((stepConfig) =>
+ steps.create(stepConfig.title, {
+ isVisible: stepConfig.isVisible,
+ isCollapsed: stepConfig.isCollapsed,
+ onCollapsedClick: stepConfig.onCollapsedClick,
+ tooltip: stepConfig.tooltip
+ }, stepConfig.content)
+ )}
{/* Execute Button */}
{config.executeButton && config.executeButton.isVisible !== false && (
@@ -118,28 +108,9 @@ function ToolFlowContent({ config }: { config: ToolFlowConfig }) {
operation: config.review.operation,
title: config.review.title,
onFileClick: config.review.onFileClick,
- onUndo: config.review.onUndo,
- isCollapsed: isCollapsed('review', false),
- onCollapsedClick: () => onToggle('review', undefined)
+ onUndo: config.review.onUndo
})}
);
-}
-
-export interface ToolFlowProps extends ToolFlowConfig {}
-
-export function ToolFlow(props: ToolFlowProps) {
- return (
-
-
-
- );
-}
-
-export function createToolFlow(config: ToolFlowConfig) {
- return ;
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/tools/shared/useSingleExpandController.ts b/frontend/src/components/tools/shared/useSingleExpandController.ts
deleted file mode 100644
index f2814738a..000000000
--- a/frontend/src/components/tools/shared/useSingleExpandController.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { useEffect, useMemo, useCallback } from 'react';
-import { useSingleExpansion } from './SingleExpansionContext';
-
-export function useSingleExpandController(opts: {
- filesVisible: boolean;
- stepVisibilities: (boolean | undefined)[];
- resultsVisible?: boolean;
-}) {
- const { enabled, expandedStep, setExpandedStep } = useSingleExpansion();
-
- const visibleIds = useMemo(
- () => [
- ...(opts.filesVisible === false ? [] : ['files']),
- ...opts.stepVisibilities.map((v, i) => (v === false ? null : `step-${i}`)).filter(Boolean) as string[],
- ...(opts.resultsVisible ? ['review'] : []),
- ],
- [opts.filesVisible, opts.stepVisibilities, opts.resultsVisible]
- );
-
- // If single-expand is turned off, clear selection
- useEffect(() => {
- if (!enabled && expandedStep !== null) setExpandedStep(null);
- }, [enabled]);
-
- // If the selected step becomes invisible, clear it
- useEffect(() => {
- if (!enabled) return;
- if (expandedStep && !visibleIds.includes(expandedStep)) {
- setExpandedStep(null);
- }
- }, [enabled, expandedStep, visibleIds]);
-
- // When results become visible, automatically expand them and collapse all others
- useEffect(() => {
- if (!enabled) return;
- if (opts.resultsVisible && expandedStep !== 'review') {
- setExpandedStep('review');
- }
- }, [enabled, opts.resultsVisible, expandedStep, setExpandedStep]);
-
- const onToggle = useCallback((stepId: string, original?: () => void) => {
- if (enabled) {
- // If Files is the only visible step, don't allow it to be collapsed
- if (stepId === 'files' && visibleIds.length === 1) {
- return; // Don't collapse the only visible step
- }
- setExpandedStep(expandedStep === stepId ? null : stepId);
- }
- original?.();
- }, [enabled, expandedStep, setExpandedStep, visibleIds]);
-
- const isCollapsed = useCallback((stepId: string, original?: boolean) => {
- if (!enabled) return original ?? false;
-
- // If Files is the only visible step, never collapse it
- if (stepId === 'files' && visibleIds.length === 1) {
- return false;
- }
-
- if (expandedStep == null) return true;
- return expandedStep !== stepId;
- }, [enabled, expandedStep, visibleIds]);
-
- return { visibleIds, onToggle, isCollapsed };
-}
diff --git a/frontend/src/tools/AddStamp.tsx b/frontend/src/tools/AddStamp.tsx
index 850c25542..fd580bc62 100644
--- a/frontend/src/tools/AddStamp.tsx
+++ b/frontend/src/tools/AddStamp.tsx
@@ -12,14 +12,13 @@ import LocalIcon from "../components/shared/LocalIcon";
import styles from "../components/tools/addStamp/StampPreview.module.css";
import { Tooltip } from "../components/shared/Tooltip";
import ButtonSelector from "../components/shared/ButtonSelector";
+import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
+import ObscuredOverlay from "../components/shared/ObscuredOverlay";
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
- const [collapsedType, setCollapsedType] = useState(false);
- const [collapsedFormatting, setCollapsedFormatting] = useState(true);
- const [collapsedPageSelection, setCollapsedPageSelection] = useState(false);
const [quickPositionModeSelected, setQuickPositionModeSelected] = useState(false);
const [customPositionModeSelected, setCustomPositionModeSelected] = useState(true);
@@ -48,14 +47,34 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasFiles = selectedFiles.length > 0;
const hasResults = operation.files.length > 0 || operation.downloadUrl !== null;
+ enum AddStampStep {
+ NONE = 'none',
+ PAGE_SELECTION = 'pageSelection',
+ STAMP_TYPE = 'stampType',
+ POSITION_FORMATTING = 'positionFormatting'
+ }
+
+ const accordion = useAccordionSteps({
+ noneValue: AddStampStep.NONE,
+ initialStep: AddStampStep.PAGE_SELECTION,
+ stateConditions: {
+ hasFiles,
+ hasResults
+ },
+ afterResults: () => {
+ operation.resetResults();
+ onPreviewFile?.(null);
+ }
+ });
+
const getSteps = () => {
const steps: any[] = [];
// Step 1: File settings (page selection)
steps.push({
title: t("AddStampRequest.pageSelection", "Page Selection"),
- isCollapsed: hasResults || collapsedPageSelection,
- onCollapsedClick: hasResults ? () => operation.resetResults() : () => setCollapsedPageSelection(!collapsedPageSelection),
+ isCollapsed: accordion.getCollapsedState(AddStampStep.PAGE_SELECTION),
+ onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.PAGE_SELECTION),
isVisible: hasFiles || hasResults,
content: (
@@ -72,8 +91,8 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
// Step 2: Type & Content
steps.push({
title: t("AddStampRequest.stampType", "Stamp Type"),
- isCollapsed: hasResults ? true : collapsedType,
- onCollapsedClick: hasResults ? () => operation.resetResults() : () => setCollapsedType(!collapsedType),
+ isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_TYPE),
+ onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_TYPE),
isVisible: hasFiles || hasResults,
content: (
@@ -161,8 +180,8 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
// Step 3: Formatting & Position
steps.push({
title: t("AddStampRequest.positionAndFormatting", "Position & Formatting"),
- isCollapsed: hasResults ? true : collapsedFormatting,
- onCollapsedClick: hasResults ? () => operation.resetResults() : () => setCollapsedFormatting(!collapsedFormatting),
+ isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
+ onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.POSITION_FORMATTING),
isVisible: hasFiles || hasResults,
content: (
@@ -201,27 +220,27 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
@@ -229,7 +248,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
{/* Single slider bound to selected pill */}
- {(params.parameters as any)._activePill === 'fontSize' && (
+ {params.parameters._activePill === 'fontSize' && (
{params.parameters.stampType === 'image'
@@ -259,7 +278,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
)}
- {(params.parameters as any)._activePill === 'rotation' && (
+ {params.parameters._activePill === 'rotation' && (
{t('AddStampRequest.rotation', 'Rotation')}
@@ -285,7 +304,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
)}
- {(params.parameters as any)._activePill === 'opacity' && (
+ {params.parameters._activePill === 'opacity' && (
{t('AddStampRequest.opacity', 'Opacity')}
@@ -340,13 +359,26 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
)}
- {/* Unified preview; when in quick mode, overlay grid inside preview */}
-
+ {/* Unified preview wrapped with obscured overlay if no stamp selected in step 4 */}
+
+ {t('AddStampRequest.noStampSelected', 'No stamp selected. Return to Step 3.')}
+
+ }
+ >
+
+
),
});
@@ -377,9 +409,6 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
onPreviewFile?.(null);
},
},
- forceStepNumbers: true,
- maxOneExpanded: true,
- initialExpandedStep: "files"
});
};