addition of Fake Scan tool

This commit is contained in:
EthanHealy01 2025-09-26 03:18:31 +01:00
parent fd52dc0226
commit 2119ae5d8b
12 changed files with 689 additions and 2 deletions

View File

@ -582,7 +582,8 @@
"fakeScan": {
"tags": "scan,simulate,create",
"title": "Fake Scan",
"desc": "Create a PDF that looks like it was scanned"
"desc": "Create a PDF that looks like it was scanned",
"noPreview": "No preview available, select a PDF to preview fake scan"
},
"editTableOfContents": {
"tags": "bookmarks,contents,edit",

View File

@ -671,6 +671,12 @@
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"fakeScan": {
"tags": "scan,simulate,create",
"title": "Fake Scan",
"desc": "Create a PDF that looks like it was scanned",
"noPreview": "No preview available, select a PDF to preview fake scan"
},
"replace-color": {
"tags": "color,replace,invert",
"title": "Replace and Invert Color",

View File

@ -0,0 +1,76 @@
import React, { useRef } from 'react';
import { Group, NumberInput, Slider, Stack, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
import { useAdjustFontSizeToFit } from '../../shared/fitText/textFit';
// No basic option imports here; this panel focuses on advanced sliders only
type Props = {
parameters: FakeScanParameters;
onParameterChange: <K extends keyof FakeScanParameters>(key: K, value: FakeScanParameters[K]) => void;
disabled?: boolean;
};
const FitLabel = ({ children }: { children: React.ReactNode }) => {
const ref = useRef<HTMLDivElement | null>(null);
useAdjustFontSizeToFit(ref, { maxLines: 2 });
return (
<div ref={ref} style={{ lineHeight: 1.15, minHeight: '2.3em', display: 'block' }}>{children}</div>
);
};
export default function FakeScanAdvancedPanel({ parameters, onParameterChange, disabled }: Props) {
const { t } = useTranslation();
const setAdvanced = () => {
if (!parameters.advancedEnabled) onParameterChange('advancedEnabled', true as any);
};
return (
<Stack gap="md">
<Group grow>
<div>
<FitLabel>{t('scannerEffect.brightness', 'Brightness')}</FitLabel>
<Slider min={0.5} max={1.5} step={0.01} value={parameters.brightness} onChange={(v) => { setAdvanced(); onParameterChange('brightness', v as any); }} disabled={disabled} />
</div>
<div>
<FitLabel>{t('scannerEffect.contrast', 'Contrast')}</FitLabel>
<Slider min={0.5} max={1.5} step={0.01} value={parameters.contrast} onChange={(v) => { setAdvanced(); onParameterChange('contrast', v as any); }} disabled={disabled} />
</div>
</Group>
<Group grow>
<div>
<FitLabel>{t('scannerEffect.rotation', 'Rotation')}</FitLabel>
<Slider min={-10} max={10} step={1} value={parameters.rotate} onChange={(v) => { setAdvanced(); onParameterChange('rotate', v as any); onParameterChange('rotateVariance', 0 as any); }} disabled={disabled} />
</div>
<div>
<FitLabel>{t('scannerEffect.blur', 'Blur')}</FitLabel>
<Slider min={0} max={5} step={0.1} value={parameters.blur} onChange={(v) => { setAdvanced(); onParameterChange('blur', v as any); }} disabled={disabled} />
</div>
</Group>
<Group grow>
<div>
<FitLabel>{t('scannerEffect.noise', 'Noise')}</FitLabel>
<Slider min={0} max={10} step={0.1} value={parameters.noise} onChange={(v) => { setAdvanced(); onParameterChange('noise', v as any); }} disabled={disabled} />
</div>
<div style={{ marginTop: '8px' }}>
<FitLabel>{t('scannerEffect.yellowish', 'Yellowish (simulate old paper)')}</FitLabel>
<div style={{ marginTop: '8px' }}/>
<Switch checked={parameters.yellowish} onChange={(e) => { setAdvanced(); onParameterChange('yellowish', e.currentTarget.checked as any); }} disabled={disabled} />
</div>
</Group>
<Group grow>
<div>
<FitLabel>{t('scannerEffect.resolution', 'Resolution (DPI)')}</FitLabel>
<NumberInput value={parameters.resolution} onChange={(v) => { setAdvanced(); onParameterChange('resolution', Number(v) || 72 as any); }} disabled={disabled} min={72} step={1} />
</div>
</Group>
</Stack>
);
}

View File

@ -0,0 +1,66 @@
import React, { useRef } from 'react';
import { Stack, Select, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
import { getQualityOptions, getRotationOptions, getColorspaceOptions } from './constants';
import { useAdjustFontSizeToFit } from '../../shared/fitText/textFit';
const FitLabel = ({ children }: { children: React.ReactNode }) => {
const ref = useRef<HTMLDivElement | null>(null);
useAdjustFontSizeToFit(ref, { maxLines: 2 });
return (
<div ref={ref} style={{ lineHeight: 1.15, minHeight: '2.3em', display: 'block' }}>{children}</div>
);
};
export default function FakeScanBasicSettings({
parameters,
onParameterChange,
disabled,
}: {
parameters: FakeScanParameters;
onParameterChange: <K extends keyof FakeScanParameters>(
key: K,
value: FakeScanParameters[K]
) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<Stack gap="sm">
<Select
label={<FitLabel>{t('scannerEffect.quality', 'Scan Quality')}</FitLabel>}
data={getQualityOptions(t)}
value={parameters.quality}
onChange={(v) => onParameterChange('quality', (v as any) || 'high')}
disabled={disabled}
/>
<Select
label={<FitLabel>{t('scannerEffect.rotation', 'Rotation Angle')}</FitLabel>}
data={getRotationOptions(t)}
value={parameters.rotation}
onChange={(v) => onParameterChange('rotation', (v as any) || 'slight')}
disabled={disabled}
/>
<Select
label={<FitLabel>{t('scannerEffect.colorspace', 'Colorspace')}</FitLabel>}
data={getColorspaceOptions(t)}
value={parameters.colorspace}
onChange={(v) => onParameterChange('colorspace', (v as any) || 'grayscale')}
disabled={disabled}
/>
<NumberInput
label={<FitLabel>{t('scannerEffect.border', 'Border (px)')}</FitLabel>}
value={parameters.border}
onChange={(v) => onParameterChange('border', Number(v) || 0)}
disabled={disabled}
min={0}
step={1}
/>
</Stack>
);
}

View File

@ -0,0 +1,150 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
import ObscuredOverlay from '../../shared/ObscuredOverlay';
import { useTranslation } from 'react-i18next';
type Props = {
file?: File | null;
parameters: FakeScanParameters;
};
export default function FakeScanPreview({ file, parameters }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [pageThumbnail, setPageThumbnail] = useState<string | null>(null);
const { requestThumbnail } = useThumbnailGeneration();
const { t } = useTranslation();
useEffect(() => {
let active = true;
const load = async () => {
if (!file || file.type !== 'application/pdf') {
setPageThumbnail(null);
return;
}
try {
const pageId = `${file.name}:${file.size}:${file.lastModified}:page:1`;
const thumb = await requestThumbnail(pageId, file, 1);
if (active) setPageThumbnail(thumb || null);
} catch {
if (active) setPageThumbnail(null);
}
};
load();
return () => { active = false; };
}, [file, requestThumbnail]);
const cssFilter = useMemo(() => {
// Apply basic quality preset if advanced settings are not enabled
let brightness = parameters.brightness;
let contrast = parameters.contrast;
let blur = Math.max(0, parameters.blur);
if (!parameters.advancedEnabled) {
// Apply quality presets
switch (parameters.quality) {
case 'high':
brightness = 1.02;
contrast = 1.05;
blur = 0.1;
break;
case 'medium':
brightness = 1.05;
contrast = 1.1;
blur = 0.5;
break;
case 'low':
brightness = 1.1;
contrast = 1.2;
blur = 1.0;
break;
}
}
const grayscale = parameters.colorspace === 'grayscale' ? 'grayscale(1)' : 'grayscale(0)';
const sepia = parameters.yellowish ? 'sepia(0.35)' : 'sepia(0)';
// Simulate noise via drop-shadow stacking is heavy; skip and rely on server-side
return `${grayscale} ${sepia} brightness(${brightness}) contrast(${contrast}) blur(${blur}px)`;
}, [parameters]);
const rotation = useMemo(() => {
let base = parameters.rotate;
// Apply basic rotation preset if advanced settings are not enabled
if (!parameters.advancedEnabled) {
switch (parameters.rotation) {
case 'slight':
base += 2;
break;
case 'moderate':
base += 5;
break;
case 'severe':
base += 8;
break;
case 'none':
default:
// No additional rotation
break;
}
}
const variance = parameters.rotateVariance;
// Show deterministic max variance for preview
return base + variance;
}, [parameters.rotate, parameters.rotateVariance, parameters.rotation, parameters.advancedEnabled]);
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
<div style={{ fontSize: 12, color: 'var(--text-color-muted)' }}>Preview (approximate)</div>
<div style={{ flex: 1, height: 1, background: 'var(--border-color)' }} />
</div>
<ObscuredOverlay
obscured={!pageThumbnail}
overlayMessage={(
<div style={{ fontSize: 12, color: 'var(--text-color-muted)' }}>{t("fakeScan.noPreview", "No preview available, select a PDF to preview fake scan")}</div>
)}
borderRadius={4}
>
<div
ref={containerRef}
style={{
width: '100%',
border: '1px solid var(--border-color)',
borderRadius: 8,
background: 'var(--surface-1)',
overflow: 'hidden',
aspectRatio: '8.5/11',
position: 'relative',
}}
>
{pageThumbnail && (
<img
src={pageThumbnail}
alt="page preview"
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
filter: cssFilter,
transform: `rotate(${rotation}deg)`
}}
draggable={false}
/>
)}
{!pageThumbnail && (
<div style={{
position: 'absolute', inset: 0, display: 'grid', placeItems: 'center',
color: 'var(--text-color-muted)', fontSize: 12
}}>
{t("fakeScan.noPreview", "No preview available, select a PDF to preview fake scan")}
</div>
)}
</div>
</ObscuredOverlay>
</div>
);
}

View File

@ -0,0 +1,168 @@
import React, { useRef } from 'react';
import { Stack, Select, Switch, NumberInput, Divider, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { FakeScanParameters } from '../../../hooks/tools/fakeScan/useFakeScanParameters';
import { getQualityOptions, getRotationOptions } from './constants';
import { useAdjustFontSizeToFit } from '../../shared/fitText/textFit';
export default function FakeScanSettings({
parameters,
onParameterChange,
disabled,
}: {
parameters: FakeScanParameters;
onParameterChange: <K extends keyof FakeScanParameters>(
key: K,
value: FakeScanParameters[K]
) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const FitLabel = ({ children }: { children: React.ReactNode }) => {
const ref = useRef<HTMLDivElement | null>(null);
useAdjustFontSizeToFit(ref, { maxLines: 2 });
return (
<div
ref={ref}
style={{
lineHeight: 1.15,
minHeight: '2.3em',
display: 'block'
}}
>
{children}
</div>
);
};
return (
<Stack gap="sm">
<Select
label={<FitLabel>{t('scannerEffect.quality', 'Scan Quality')}</FitLabel>}
data={getQualityOptions(t)}
value={parameters.quality}
onChange={(v) => onParameterChange('quality', (v as any) || 'high')}
disabled={disabled}
/>
<Select
label={<FitLabel>{t('scannerEffect.rotation', 'Rotation Angle')}</FitLabel>}
data={getRotationOptions(t)}
value={parameters.rotation}
onChange={(v) => onParameterChange('rotation', (v as any) || 'slight')}
disabled={disabled}
/>
<Divider />
<Switch
checked={parameters.advancedEnabled}
onChange={(e) => onParameterChange('advancedEnabled', e.currentTarget.checked)}
label={<FitLabel>{t('scannerEffect.advancedSettings', 'Enable Advanced Scan Settings')}</FitLabel>}
disabled={disabled}
/>
{parameters.advancedEnabled && (
<Stack gap="xs" style={{ border: '1px solid var(--border-color)', padding: 12, borderRadius: 8 }}>
<Select
label={<FitLabel>{t('scannerEffect.colorspace', 'Colorspace')}</FitLabel>}
data={[
{ value: 'grayscale', label: t('scannerEffect.colorspace.grayscale', 'Grayscale') },
{ value: 'color', label: t('scannerEffect.colorspace.color', 'Color') },
]}
value={parameters.colorspace}
onChange={(v) => onParameterChange('colorspace', (v as any) || 'grayscale')}
disabled={disabled}
/>
<Divider />
<Group grow>
<NumberInput
label={<FitLabel>{t('scannerEffect.border', 'Border (px)')}</FitLabel>}
value={parameters.border}
onChange={(v) => onParameterChange('border', Number(v) || 0)}
disabled={disabled}
min={0}
step={1}
/>
<NumberInput
label={<FitLabel>{t('scannerEffect.rotate', 'Base Rotation (degrees)')}</FitLabel>}
value={parameters.rotate}
onChange={(v) => onParameterChange('rotate', Number(v) || 0)}
disabled={disabled}
step={1}
/>
<NumberInput
label={<FitLabel>{t('scannerEffect.rotateVariance', 'Rotation Variance (degrees)')}</FitLabel>}
value={parameters.rotateVariance}
onChange={(v) => onParameterChange('rotateVariance', Number(v) || 0)}
disabled={disabled}
step={1}
/>
</Group>
<Divider />
<Group grow>
<NumberInput
label={<FitLabel>{t('scannerEffect.brightness', 'Brightness')}</FitLabel>}
value={parameters.brightness}
onChange={(v) => onParameterChange('brightness', Number(v) || 0)}
disabled={disabled}
step={0.01}
/>
<NumberInput
label={<FitLabel>{t('scannerEffect.contrast', 'Contrast')}</FitLabel>}
value={parameters.contrast}
onChange={(v) => onParameterChange('contrast', Number(v) || 0)}
disabled={disabled}
step={0.01}
/>
</Group>
<Divider />
<Group grow>
<NumberInput
label={<FitLabel>{t('scannerEffect.blur', 'Blur')}</FitLabel>}
value={parameters.blur}
onChange={(v) => onParameterChange('blur', Number(v) || 0)}
disabled={disabled}
step={0.1}
/>
<NumberInput
label={<FitLabel>{t('scannerEffect.noise', 'Noise')}</FitLabel>}
value={parameters.noise}
onChange={(v) => onParameterChange('noise', Number(v) || 0)}
disabled={disabled}
step={0.1}
/>
</Group>
<Divider />
<Group grow>
<Switch
checked={parameters.yellowish}
onChange={(e) => onParameterChange('yellowish', e.currentTarget.checked)}
label={<FitLabel>{t('scannerEffect.yellowish', 'Yellowish (simulate old paper)')}</FitLabel>}
disabled={disabled}
/>
<NumberInput
label={<FitLabel>{t('scannerEffect.resolution', 'Resolution (DPI)')}</FitLabel>}
value={parameters.resolution}
onChange={(v) => onParameterChange('resolution', Number(v) || 72)}
disabled={disabled}
step={1}
min={72}
/>
</Group>
</Stack>
)}
</Stack>
);
}

View File

@ -0,0 +1,21 @@
import { TFunction } from 'i18next';
export const getQualityOptions = (t: TFunction) => [
{ value: 'low', label: t('scannerEffect.quality.low', 'Low') },
{ value: 'medium', label: t('scannerEffect.quality.medium', 'Medium') },
{ value: 'high', label: t('scannerEffect.quality.high', 'High') },
];
export const getRotationOptions = (t: TFunction) => [
{ value: 'none', label: t('scannerEffect.rotation.none', 'None') },
{ value: 'slight', label: t('scannerEffect.rotation.slight', 'Slight') },
{ value: 'moderate', label: t('scannerEffect.rotation.moderate', 'Moderate') },
{ value: 'severe', label: t('scannerEffect.rotation.severe', 'Severe') },
];
export const getColorspaceOptions = (t: TFunction) => [
{ value: 'grayscale', label: t('scannerEffect.colorspace.grayscale', 'Grayscale') },
{ value: 'color', label: t('scannerEffect.colorspace.color', 'Color') },
];

View File

@ -56,6 +56,7 @@ export interface ToolFlowConfig {
steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig;
review: ReviewStepConfig;
preview?: React.ReactNode;
forceStepNumbers?: boolean;
}
@ -90,6 +91,9 @@ export function createToolFlow(config: ToolFlowConfig) {
}, stepConfig.content)
)}
{/* Preview */}
{config.preview}
{/* Execute Button */}
{config.executeButton && config.executeButton.isVisible !== false && (
<OperationButton

View File

@ -67,6 +67,7 @@ import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import RotateSettings from "../components/tools/rotate/RotateSettings";
import Redact from "../tools/Redact";
import FakeScan from "../tools/FakeScan";
import AdjustPageScale from "../tools/AdjustPageScale";
import { ToolId } from "../types/toolId";
import MergeSettings from '../components/tools/merge/MergeSettings';
@ -669,10 +670,13 @@ export function useFlatToolRegistry(): ToolRegistry {
fakeScan: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.fakeScan.title", "Scanner Effect"),
component: null,
component: FakeScan,
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["scanner-effect"],
settingsComponent: React.lazy(() => import("../components/tools/fakeScan/FakeScanSettings")),
synonyms: getSynonyms(t, "fakeScan"),
},

View File

@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { FakeScanParameters, defaultParameters } from './useFakeScanParameters';
export const buildFakeScanFormData = (parameters: FakeScanParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('quality', parameters.quality);
formData.append('rotation', parameters.rotation);
formData.append('advancedEnabled', String(parameters.advancedEnabled));
if (parameters.advancedEnabled) {
formData.append('colorspace', parameters.colorspace);
formData.append('border', String(parameters.border));
formData.append('rotate', String(parameters.rotate));
formData.append('rotateVariance', String(parameters.rotateVariance));
formData.append('brightness', String(parameters.brightness));
formData.append('contrast', String(parameters.contrast));
formData.append('blur', String(parameters.blur));
formData.append('noise', String(parameters.noise));
formData.append('yellowish', String(parameters.yellowish));
formData.append('resolution', String(parameters.resolution));
}
return formData;
};
export const fakeScanOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildFakeScanFormData,
operationType: 'fakeScan',
endpoint: '/api/v1/misc/scanner-effect',
defaultParameters,
} as const;
export const useFakeScanOperation = () => {
const { t } = useTranslation();
return useToolOperation<FakeScanParameters>({
...fakeScanOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('fakeScan.error.failed', 'An error occurred while applying the scanner effect.')
)
});
};

View File

@ -0,0 +1,45 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface FakeScanParameters extends BaseParameters {
quality: 'low' | 'medium' | 'high';
rotation: 'none' | 'slight' | 'moderate' | 'severe';
advancedEnabled: boolean;
colorspace: 'grayscale' | 'color';
border: number;
rotate: number;
rotateVariance: number;
brightness: number;
contrast: number;
blur: number;
noise: number;
yellowish: boolean;
resolution: number;
}
export const defaultParameters: FakeScanParameters = {
quality: 'high',
rotation: 'slight',
advancedEnabled: false,
colorspace: 'grayscale',
border: 20,
rotate: 0,
rotateVariance: 2,
brightness: 1.0,
contrast: 1.0,
blur: 1.0,
noise: 8.0,
yellowish: false,
resolution: 300,
};
export type FakeScanParametersHook = BaseParametersHook<FakeScanParameters>;
export const useFakeScanParameters = (): FakeScanParametersHook => {
return useBaseParameters<FakeScanParameters>({
defaultParameters,
endpointName: 'scanner-effect',
});
};

View File

@ -0,0 +1,100 @@
import { useTranslation } from 'react-i18next';
import { createToolFlow } from '../components/tools/shared/createToolFlow';
import { BaseToolProps, ToolComponent } from '../types/tool';
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
import { useFakeScanParameters } from '../hooks/tools/fakeScan/useFakeScanParameters';
import { useFakeScanOperation } from '../hooks/tools/fakeScan/useFakeScanOperation';
import FakeScanBasicSettings from '../components/tools/fakeScan/FakeScanBasicSettings';
import FakeScanAdvancedPanel from '../components/tools/fakeScan/FakeScanAdvancedPanel';
import FakeScanPreview from '../components/tools/fakeScan/FakeScanPreview';
import { useAccordionSteps } from '../hooks/tools/shared/useAccordionSteps';
const FakeScan = (props: BaseToolProps) => {
const { t } = useTranslation();
const base = useBaseTool(
'fakeScan',
useFakeScanParameters,
useFakeScanOperation,
props
);
enum FakeScanStep {
NONE = 'none',
BASIC = 'basic',
ADVANCED = 'advanced'
}
const accordion = useAccordionSteps<FakeScanStep>({
noneValue: FakeScanStep.NONE,
initialStep: FakeScanStep.BASIC,
stateConditions: {
hasFiles: base.hasFiles,
hasResults: base.hasResults
},
afterResults: base.handleSettingsReset
});
const firstFile = base.selectedFiles[0] || null;
const canPreview = !!firstFile && firstFile.type === 'application/pdf';
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t("scannerEffect.basicSettings", "Basic Settings"),
isCollapsed: accordion.getCollapsedState(FakeScanStep.BASIC),
onCollapsedClick: () => accordion.handleStepToggle(FakeScanStep.BASIC),
content: (
<FakeScanBasicSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
{
title: t("scannerEffect.advancedSettings", "Advanced Settings"),
isCollapsed: accordion.getCollapsedState(FakeScanStep.ADVANCED),
onCollapsedClick: () => accordion.handleStepToggle(FakeScanStep.ADVANCED),
content: (
<FakeScanAdvancedPanel
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
preview: !base.hasResults ? (
<FakeScanPreview
parameters={base.params.parameters}
file={canPreview ? firstFile : null}
/>
) : null,
executeButton: {
text: t('fakeScan.submit', 'Create Scanner Effect'),
isVisible: !base.hasResults,
loadingText: t('loading'),
onClick: base.handleExecute,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: base.hasResults,
operation: base.operation,
title: t('fakeScan.title', 'Scanner Effect Results'),
onFileClick: base.handleThumbnailClick,
onUndo: base.handleUndo,
},
forceStepNumbers: true,
});
};
export default FakeScan as ToolComponent;