Compare commits

...
Author SHA1 Message Date
EthanHealy01 282f18705d draft: Make Tool Steps Seperate Pages 2026-06-17 16:44:02 +01:00
11 changed files with 824 additions and 58 deletions
@@ -4,6 +4,10 @@ apply = "Apply"
applyAndContinue = "Save & Leave"
areYouSure = "Are you sure you want to leave?"
back = "Back"
wizardContinue = "Continue"
wizardProgress = "Step progress"
wizardStepProgress = "Step {{current}} of {{total}}"
wizardCompleteStep = "Complete this step to continue."
black = "Black"
blue = "Blue"
cancel = "Cancel"
@@ -3871,6 +3875,9 @@ title = "File to PDF"
[fileUpload]
dropFilesHere = "Drop files here or click the upload button"
dropTitle = "Drop your file here"
dropSubtitle = "or pick one from your computer"
addAtLeast = "Add at least {{count}} files"
dropFilesHereOpen = "Drop files here or click the open button"
filesAvailable = "files available"
loadFromStorage = "Load from Storage"
@@ -6242,6 +6249,8 @@ applyWarning = "⚠️ Permanent application, cannot be undone and the data unde
colorLabel = "Redaction Color"
controlsTitle = "Manual Redaction Controls"
instructions = "Select text or draw areas on the PDF to mark content for redaction."
markArea = "Mark Area"
markText = "Mark Text"
title = "Redaction Tools"
[redact.modeSelector]
@@ -66,7 +66,18 @@ export default function ToolPanel({
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
<ScrollArea
h="100%"
styles={{
// Let the tool fill the panel height so the slide wizard can
// center its content and pin its CTAs to the bottom.
content: {
minHeight: "100%",
display: "flex",
flexDirection: "column",
},
}}
>
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
@@ -1,6 +1,8 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useCallback } from "react";
import { Button, Stack, Text, Divider, ColorInput } from "@mantine/core";
import { Button, Stack, Text, Divider, ColorInput, Group } from "@mantine/core";
import HighlightAltIcon from "@mui/icons-material/HighlightAlt";
import CropFreeIcon from "@mui/icons-material/CropFree";
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useSignature } from "@app/contexts/SignatureContext";
@@ -21,7 +23,8 @@ export default function ManualRedactionControls({
// Use our RedactionContext which bridges to EmbedPDF
const {
activateManualRedact,
activateTextSelection,
activateMarquee,
redactionsApplied,
setActiveType,
setManualRedactColor,
@@ -50,9 +53,9 @@ export default function ManualRedactionControls({
// Guard: pause auto-reactivation during save/export to avoid interfering with EmbedPDF
const isSavingRef = useRef(false);
// Keep redaction tool active at all times while this component is mounted.
// If anything deactivates it (annotation tools, text selection, file switch, etc.)
// this re-enables it automatically — no manual "Activate" button needed.
// Keep a redaction tool active while this component is mounted. If anything
// deactivates it (annotation tools, file switch, etc.) this re-enables it —
// defaulting to text selection so "Mark Text" is the initial active mode.
useEffect(() => {
if (
disabled ||
@@ -77,7 +80,7 @@ export default function ManualRedactionControls({
// Small delay to avoid racing with EmbedPDF's own state updates
const timer = setTimeout(() => {
if (!isSavingRef.current) {
activateManualRedact();
activateTextSelection();
}
}, 50);
return () => clearTimeout(timer);
@@ -90,7 +93,7 @@ export default function ManualRedactionControls({
showNavigationWarning,
setAnnotationMode,
signatureApiRef,
activateManualRedact,
activateTextSelection,
]);
// Reset redaction tool when switching between files
@@ -123,6 +126,28 @@ export default function ManualRedactionControls({
const isApiReady = isBridgeReady;
// Which manual-redaction sub-mode is active (drives the tool button highlight).
const isSelectionActive = activeType === "redactSelection";
const isMarqueeActive = activeType === "marqueeRedact";
// Switch to a redaction sub-mode, first stepping out of annotation mode/tools.
const switchMode = useCallback(
(activate: () => void) => {
if (isAnnotationMode) {
setAnnotationMode(false);
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log("Unable to deactivate annotation tools:", error);
}
}
}
activate();
},
[isAnnotationMode, setAnnotationMode, signatureApiRef],
);
return (
<>
<Divider my="sm" />
@@ -138,6 +163,54 @@ export default function ManualRedactionControls({
)}
</Text>
<Group gap="sm" grow wrap="nowrap">
<Button
variant={
isSelectionActive && !isAnnotationMode ? "filled" : "outline"
}
color={isSelectionActive && !isAnnotationMode ? "blue" : "gray"}
leftSection={
<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />
}
onClick={() => switchMode(activateTextSelection)}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: { minWidth: 0 },
label: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
}}
>
{t("redact.manual.markText", "Mark Text")}
</Button>
<Button
variant={
isMarqueeActive && !isAnnotationMode ? "filled" : "outline"
}
color={isMarqueeActive && !isAnnotationMode ? "blue" : "gray"}
leftSection={
<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />
}
onClick={() => switchMode(activateMarquee)}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: { minWidth: 0 },
label: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
}}
>
{t("redact.manual.markArea", "Mark Area")}
</Button>
</Group>
<ColorInput
label={t("redact.manual.colorLabel", "Redaction Colour")}
value={manualRedactColor}
@@ -24,7 +24,7 @@ export interface ReviewToolStepProps<TParams = unknown> {
onCollapsedClick?: () => void;
}
function ReviewStepContent<TParams = unknown>({
export function ReviewStepContent<TParams = unknown>({
operation,
onFileClick,
onUndo,
@@ -0,0 +1,126 @@
/* Slide-based tool wizard: a calm segmented progress bar, one step per slide
with centered content, and big Back / Continue CTAs pinned to the bottom. */
.tool-step-wizard {
/* Fill the panel height (the parent ScrollArea content is a flex column with
min-height:100%). flex-basis:0 makes this resolve to exactly the viewport
height so the body can scroll internally and the CTAs stay pinned. */
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
width: 100%;
}
.tool-step-wizard__title {
flex-shrink: 0;
padding: 0.5rem 0.75rem 0;
}
/* ---- Progress bar ---- */
.tool-step-wizard__progress {
display: flex;
gap: 6px;
align-items: center;
padding: 0.85rem 1rem 0.45rem;
flex-shrink: 0;
}
.tool-step-wizard__segment {
all: unset;
flex: 1 1 0;
height: 4px;
border-radius: 9999px;
background: var(--onboarding-step-inactive);
position: relative;
overflow: hidden;
cursor: default;
box-sizing: border-box;
}
.tool-step-wizard__segment[data-jumpable] {
cursor: pointer;
}
.tool-step-wizard__segment-fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--mantine-color-blue-filled);
transform: scaleX(0);
transform-origin: left center;
transition: transform 0.3s ease;
}
.tool-step-wizard__segment[data-filled] .tool-step-wizard__segment-fill {
transform: scaleX(1);
}
.tool-step-wizard__caption {
flex-shrink: 0;
padding: 0 1rem 0.25rem;
}
/* ---- Slide body: top-aligned content that scrolls when tall ---- */
.tool-step-wizard__body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
/* Horizontal padding lives on the slide + CTA buttons so the CTA's top
border can span edge-to-edge. */
padding-top: 0.75rem;
}
.tool-step-wizard__slide {
width: 100%;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 0 1rem;
animation: wizard-slide-in 0.2s ease;
}
@keyframes wizard-slide-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.tool-step-wizard__slide-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* ---- CTAs: sit right after the content, stick to the bottom on overflow ---- */
.tool-step-wizard__cta {
position: sticky;
bottom: 0;
/* Flex column so each button stretches to the container width minus its own
horizontal margin (mx="md") — never width:100% + margin, which overflows. */
display: flex;
flex-direction: column;
margin-top: 0.75rem;
padding-bottom: 0.85rem;
border-top: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
z-index: 1;
}
.tool-step-wizard__cta-btn {
min-height: 2.85rem !important;
}
@media (prefers-reduced-motion: reduce) {
.tool-step-wizard__segment-fill,
.tool-step-wizard__slide {
transition: none !important;
animation: none !important;
}
}
@@ -0,0 +1,281 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Flex, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import {
ToolWorkflowTitle,
ToolWorkflowTitleProps,
} from "@app/components/tools/shared/ToolWorkflowTitle";
import type { TooltipTip } from "@app/types/tips";
import "@app/components/tools/shared/ToolStepWizard.css";
export interface WizardSlide {
key: string;
title: string;
content: React.ReactNode;
/** Whether the user may advance past this slide (gates Continue + forward jumps). */
isValid: boolean;
/** Tooltip shown on the Continue button when this slide blocks advancing. */
blockedHint?: string;
/**
* Hide the bottom Continue button for this slide — used by the Files slide,
* whose own upload CTA advances the wizard once enough files are added.
*/
hideContinue?: boolean;
/**
* Invoked when the user navigates to this slide by clicking its progress-bar
* segment in terminal (results) mode — used to reset the results so the tool
* returns to editing this step.
*/
onActivate?: () => void;
tooltip?: {
content?: React.ReactNode;
tips?: TooltipTip[];
header?: {
title: string;
logo?: React.ReactNode;
};
};
}
export interface ToolStepWizardProps {
slides: WizardSlide[];
title?: ToolWorkflowTitleProps;
/** Primary CTA on the final slide (the execute button). */
executeSlot?: React.ReactNode;
/** Rendered just beneath the execute button on the final slide. */
belowExecuteButton?: React.ReactNode;
/** Optional preview content shown on the final slide above the CTA. */
preview?: React.ReactNode;
/**
* Terminal (results) mode: the flow is complete, so the wizard sits on the
* last slide with the whole progress bar filled and no Continue/Back CTAs (the
* review content carries its own undo/download). Clicking an earlier bar
* segment fires that slide's onActivate (reset) to return to editing it.
*/
terminal?: boolean;
}
/**
* Renders a tool's steps as a slide wizard: a segmented progress bar at the top,
* one step per slide with centered content, and large Back / Continue CTAs at
* the bottom. The final slide swaps Continue for the tool's execute button. A
* single-step flow drops the progress bar / step counter / title and just shows
* the centered content and its primary action.
*
* Navigation is gated: Continue and forward jumps are blocked until the current
* slide is valid; Back (and clicking an earlier segment) is always allowed.
*/
export function ToolStepWizard({
slides,
title,
executeSlot,
belowExecuteButton,
preview,
terminal = false,
}: ToolStepWizardProps) {
const { t } = useTranslation();
const [activeKey, setActiveKey] = useState<string>(() => slides[0]?.key);
// Keep activeKey valid as the slide set changes (e.g. the Files slide drops
// out once a file is selected). Falls back to the first slide.
useEffect(() => {
if (slides.length > 0 && !slides.some((s) => s.key === activeKey)) {
setActiveKey(slides[0].key);
}
}, [slides, activeKey]);
if (slides.length === 0) return null;
// In terminal (results) mode the flow is complete: lock onto the last slide.
const rawIndex = slides.findIndex((s) => s.key === activeKey);
const currentIndex = terminal
? slides.length - 1
: rawIndex < 0
? 0
: rawIndex;
const current = slides[currentIndex];
const isLast = currentIndex === slides.length - 1;
const canAdvance = current.isValid;
const showBack = !terminal && currentIndex > 0;
// A single-step flow is just centered content + its action — no progress bar,
// step counter, or title (a lone full-width segment reads as an odd solid
// line). The chrome appears only with 2+ steps.
const showChrome = slides.length > 1;
const showContinue = !isLast && !current.hideContinue;
const hasCta = isLast
? Boolean(executeSlot) || Boolean(belowExecuteButton)
: showContinue || showBack;
// Forward jumps require every intervening slide to be valid; back is free.
// In terminal (results) mode every earlier step is reachable to go back and edit.
const canJumpTo = (target: number) => {
if (terminal) return target < currentIndex;
if (target === currentIndex) return false;
if (target < currentIndex) return true;
for (let i = currentIndex; i < target; i++) {
if (!slides[i].isValid) return false;
}
return true;
};
const goTo = (target: number) => {
if (target < 0 || target >= slides.length) return;
// In terminal mode, navigating back resets the results (returns to editing).
if (terminal) slides[target].onActivate?.();
setActiveKey(slides[target].key);
};
return (
<div className="tool-step-wizard">
{title && (
<div className="tool-step-wizard__title">
<ToolWorkflowTitle {...title} />
</div>
)}
{showChrome && (
<div
className="tool-step-wizard__progress"
role="tablist"
aria-label={t("wizardProgress", "Step progress")}
>
{slides.map((slide, index) => {
const filled = index <= currentIndex;
const isCurrent = index === currentIndex;
const jumpable = canJumpTo(index);
return (
<button
key={slide.key}
type="button"
className="tool-step-wizard__segment"
data-filled={filled || undefined}
data-current={isCurrent || undefined}
data-jumpable={jumpable || undefined}
aria-current={isCurrent ? "step" : undefined}
aria-label={`${t(
"wizardStepProgress",
"Step {{current}} of {{total}}",
{
current: index + 1,
total: slides.length,
},
)}: ${slide.title}`}
disabled={!jumpable && !isCurrent}
onClick={() => jumpable && goTo(index)}
>
<span className="tool-step-wizard__segment-fill" />
</button>
);
})}
</div>
)}
{showChrome && (
<Text
className="tool-step-wizard__caption"
size="xs"
c="dimmed"
ta="center"
>
{t("wizardStepProgress", "Step {{current}} of {{total}}", {
current: currentIndex + 1,
total: slides.length,
})}
</Text>
)}
<div className="tool-step-wizard__body">
<div className="tool-step-wizard__slide" key={current.key}>
{showChrome &&
(current.tooltip ? (
<Tooltip
content={current.tooltip.content}
tips={current.tooltip.tips}
header={current.tooltip.header}
sidebarTooltip={true}
pinOnClick={true}
>
<Flex align="center" justify="center" gap="xs">
<Text fw={600} size="md">
{current.title}
</Text>
<LocalIcon
icon="info-outline-rounded"
width="1.25rem"
height="1.25rem"
style={{ color: "var(--icon-files-color)" }}
/>
</Flex>
</Tooltip>
) : (
<Text fw={600} size="md" ta="center">
{current.title}
</Text>
))}
<div className="tool-step-wizard__slide-content">
{current.content}
</div>
{isLast && preview && <div>{preview}</div>}
</div>
{hasCta && (
<div className="tool-step-wizard__cta">
{isLast ? (
(executeSlot ?? null)
) : showContinue ? (
!canAdvance && current.blockedHint ? (
<Tooltip content={current.blockedHint} position="top" arrow>
<Box mx="md" mt="md">
<Button
fullWidth
disabled
size="md"
color="blue"
className="tool-step-wizard__cta-btn"
data-testid="wizard-continue"
>
{t("wizardContinue", "Continue")}
</Button>
</Box>
</Tooltip>
) : (
<Button
mx="md"
mt="md"
size="md"
color="blue"
disabled={!canAdvance}
onClick={() => canAdvance && goTo(currentIndex + 1)}
className="tool-step-wizard__cta-btn"
data-testid="wizard-continue"
>
{t("wizardContinue", "Continue")}
</Button>
)
) : null}
{isLast && belowExecuteButton}
{showBack && (
<Button
mx="md"
mt="sm"
size="md"
variant="default"
onClick={() => goTo(currentIndex - 1)}
className="tool-step-wizard__cta-btn"
data-testid="wizard-back"
>
{t("back", "Back")}
</Button>
)}
</div>
)}
</div>
</div>
);
}
export default ToolStepWizard;
@@ -0,0 +1,42 @@
/* Large, clean upload CTA used as the Files slide in the tool step wizard. */
.wizard-files-dropzone {
width: 100%;
border: 1.5px dashed var(--border-subtle, var(--mantine-color-default-border));
border-radius: var(--mantine-radius-lg, 1rem);
background: transparent;
padding: 2.25rem 1.25rem;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
}
.wizard-files-dropzone:hover {
border-color: var(--mantine-color-blue-filled);
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 5%,
transparent
);
}
.wizard-files-dropzone[data-accept] {
border-color: var(--mantine-color-blue-filled);
background: var(--mantine-color-blue-light);
}
.wizard-files-dropzone[data-reject] {
border-color: var(--mantine-color-red-filled);
}
.wizard-files-dropzone__icon {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
margin-bottom: 0.25rem;
}
@@ -0,0 +1,104 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { StirlingFile } from "@app/types/fileContext";
import "@app/components/tools/shared/WizardFilesStep.css";
export interface WizardFilesStepProps {
selectedFiles: StirlingFile[];
minFiles?: number;
}
/**
* The Files slide of the tool step wizard: a large drag-and-drop upload CTA.
* Adding enough files auto-advances the wizard (the slide drops out), so this
* slide owns the upload action and has no Continue button of its own.
*/
export function WizardFilesStep({
selectedFiles,
minFiles = 1,
}: WizardFilesStepProps) {
const { t } = useTranslation();
const { addFiles } = useFileHandler();
const { openFilesModal } = useFilesModalContext();
const terminology = useFileActionTerminology();
const handleDrop = (files: File[]) => {
if (files.length > 0) void addFiles(files);
};
const handleUpload = () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = ".pdf,application/pdf";
input.onchange = (event) => {
const files = Array.from((event.target as HTMLInputElement).files || []);
if (files.length > 0) void addFiles(files);
};
input.click();
};
const needsMore = minFiles > 1;
const count = selectedFiles.length;
return (
<Dropzone
onDrop={handleDrop}
multiple
activateOnClick={false}
enablePointerEvents
className="wizard-files-dropzone"
aria-label={terminology.dropFilesHere}
>
<Stack align="center" gap="xs">
<div className="wizard-files-dropzone__icon">
<LocalIcon icon="upload" width="1.6rem" height="1.6rem" />
</div>
<Text fw={600} size="lg" ta="center">
{t("fileUpload.dropTitle", "Drop your file here")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{needsMore
? t("fileUpload.addAtLeast", "Add at least {{count}} files", {
count: minFiles,
})
: t("fileUpload.dropSubtitle", "or pick one from your computer")}
</Text>
{count > 0 && (
<Text size="xs" c="dimmed" ta="center">
{t("filesSelected", "{{count}} files", { count })}
</Text>
)}
<Group gap="sm" justify="center" mt="sm">
<Button
onClick={handleUpload}
leftSection={
<LocalIcon
icon="upload"
width="1rem"
height="1rem"
style={{ color: "white" }}
/>
}
>
{terminology.uploadFromComputer}
</Button>
<Button
variant="default"
onClick={() => openFilesModal()}
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
>
{terminology.addFiles}
</Button>
</Group>
</Stack>
</Dropzone>
);
}
export default WizardFilesStep;
@@ -5,6 +5,12 @@ import {
ToolStepProvider,
} from "@app/components/tools/shared/ToolStep";
import { ScopedOperationButton } from "@app/components/tools/shared/ScopedOperationButton";
import {
ToolStepWizard,
WizardSlide,
} from "@app/components/tools/shared/ToolStepWizard";
import { WizardFilesStep } from "@app/components/tools/shared/WizardFilesStep";
import { ReviewStepContent } from "@app/components/tools/shared/ReviewToolStep";
import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
import {
ToolWorkflowTitle,
@@ -13,6 +19,7 @@ import {
import { StirlingFile } from "@app/types/fileContext";
import type { TooltipTip } from "@app/types/tips";
import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes";
import i18n from "@app/i18n";
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
@@ -27,6 +34,12 @@ export interface MiddleStepConfig {
isVisible?: boolean;
isCollapsed?: boolean;
onCollapsedClick?: () => void;
/**
* In the slide wizard, whether the user may advance past this step. Defaults
* to true (the step is always passable). Set false to block Continue and
* forward jumps until the step's inputs are valid.
*/
canContinue?: boolean;
content: React.ReactNode;
tooltip?: {
content?: React.ReactNode;
@@ -98,11 +111,141 @@ export interface ToolFlowConfig<TParams = unknown> {
export function createToolFlow<TParams = unknown>(
config: ToolFlowConfig<TParams>,
) {
const steps = createToolSteps();
// Execute button, shared by the wizard (final-slide CTA) and the accordion.
const renderExecuteButton = (): React.ReactNode => {
const eb = config.executeButton;
if (!eb || eb.isVisible === false) return null;
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
// Explicit disabledReason wins; otherwise derive from structured fields.
const effectiveDisabledReason: ExecuteDisabledReason =
eb.disabledReason !== undefined
? eb.disabledReason
: eb.endpointEnabled === false
? "endpointUnavailable"
: !hasFiles
? "noFiles"
: eb.paramsValid === false
? "invalidParams"
: null;
return (
<ScopedOperationButton
selectedFiles={config.files.selectedFiles ?? []}
disableScopeHints={eb.disableScopeHints}
onClick={eb.onClick}
isLoading={config.review.operation.isLoading}
disabled={eb.disabled}
disabledReason={effectiveDisabledReason}
loadingText={eb.loadingText}
submitText={eb.text}
showCloudBadge={
eb.showCloudBadge ?? config.review.operation.willUseCloud ?? false
}
data-testid={eb.testId}
data-tour="run-button"
/>
);
};
// ---- Build the pre-execution slide list for the wizard ----
const minFiles = config.files.minFiles ?? 1;
const filesCount = config.files.selectedFiles?.length ?? 0;
const filesSatisfied = filesCount >= minFiles;
const filesVisible = config.files.isVisible !== false;
// Mirror the accordion's "collapse the Files step once selected": the Files
// slide only appears while files are still needed.
const includeFilesSlide = filesVisible && !filesSatisfied;
const middleSteps = config.steps.filter((s) => s.isVisible !== false);
const middleSlides: WizardSlide[] = middleSteps.map((step, index) => ({
key: `step-${index}-${step.title}`,
title: step.title,
content: step.content,
tooltip: step.tooltip,
isValid: step.canContinue ?? true,
blockedHint: i18n.t(
"wizardCompleteStep",
"Complete this step to continue.",
),
}));
// Key by tool identity so switching tools resets the wizard, while
// adding/removing slides within a tool is handled by the wizard itself.
const wizardKey = `${config.executeButton?.text ?? ""}|${middleSteps
.map((s) => s.title)
.join(">")}`;
// ---- Results view: a completed progress bar + the review content (no
// collapsed step sections). The review is the final, terminal slide. ----
if (config.review.isVisible) {
// Clicking an earlier segment returns to editing that step: run the step's
// own reset (clears preview etc.) and clear results so we leave results mode.
const resultSlides: WizardSlide[] = [
...middleSlides.map((slide, index) => ({
...slide,
onActivate: () => {
middleSteps[index].onCollapsedClick?.();
config.review.operation.resetResults();
},
})),
{
key: "__review__",
title: config.review.title,
content: (
<ReviewStepContent
operation={config.review.operation}
onFileClick={config.review.onFileClick}
onUndo={config.review.onUndo}
/>
),
isValid: true,
},
];
return (
<ToolStepWizard
key={wizardKey}
slides={resultSlides}
title={config.title}
terminal
/>
);
}
// ---- Pre-execution flow ----
const wizardSlides: WizardSlide[] = [];
if (includeFilesSlide) {
wizardSlides.push({
key: "__files__",
title: i18n.t("files.title", "Files"),
content: (
<WizardFilesStep
selectedFiles={config.files.selectedFiles}
minFiles={config.files.minFiles}
/>
),
isValid: filesSatisfied,
// The upload CTA advances the wizard once enough files are added.
hideContinue: true,
});
}
wizardSlides.push(...middleSlides);
if (wizardSlides.length >= 1) {
return (
<ToolStepWizard
key={wizardKey}
slides={wizardSlides}
title={config.title}
executeSlot={renderExecuteButton()}
belowExecuteButton={config.belowExecuteButton}
preview={filesCount > 0 ? config.preview : undefined}
/>
);
}
// ---- Accordion fallback (rare zero-slide tool: file selected, no steps) ----
const steps = createToolSteps();
return (
<Stack gap="sm" p="sm">
{/* <Stack gap="sm" p="sm" h="100%" w="100%" style={{ overflow: 'auto' }}> */}
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{config.title && <ToolWorkflowTitle {...config.title} />}
@@ -131,50 +274,19 @@ export function createToolFlow<TParams = unknown>(
{/* Preview (outside steps, above execute button).
Hide when review is visible or when no files are selected. */}
{!config.review.isVisible &&
(config.files.selectedFiles?.length ?? 0) > 0 &&
config.preview}
{!config.review.isVisible && filesCount > 0 && config.preview}
{/* Execute Button */}
{config.executeButton &&
config.executeButton.isVisible !== false &&
(() => {
const eb = config.executeButton;
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
// Compute the disabled reason from structured fields; explicit disabledReason wins if set.
const effectiveDisabledReason: ExecuteDisabledReason =
eb.disabledReason !== undefined
? eb.disabledReason
: eb.endpointEnabled === false
? "endpointUnavailable"
: !hasFiles
? "noFiles"
: eb.paramsValid === false
? "invalidParams"
: null;
return (
<>
<ScopedOperationButton
selectedFiles={config.files.selectedFiles ?? []}
disableScopeHints={eb.disableScopeHints}
onClick={eb.onClick}
isLoading={config.review.operation.isLoading}
disabled={eb.disabled}
disabledReason={effectiveDisabledReason}
loadingText={eb.loadingText}
submitText={eb.text}
showCloudBadge={
eb.showCloudBadge ??
config.review.operation.willUseCloud ??
false
}
data-testid={eb.testId}
data-tour="run-button"
/>
{config.belowExecuteButton}
</>
);
})()}
{(() => {
const button = renderExecuteButton();
if (!button) return null;
return (
<>
{button}
{config.belowExecuteButton}
</>
);
})()}
{/* Review Step */}
{steps.createReviewStep({
@@ -80,6 +80,12 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
enableRedact: () => {
redactionProvides?.enableRedact();
},
enableRedactSelection: () => {
redactionProvides?.enableRedactSelection();
},
enableMarqueeRedact: () => {
redactionProvides?.enableMarqueeRedact();
},
isRedactActive: () => {
return redactionProvides?.isRedactActive() ?? false;
},
@@ -17,6 +17,8 @@ import { RedactionMode } from "@embedpdf/plugin-redaction";
export interface RedactionAPI {
toggleRedact: () => void;
enableRedact: () => void;
enableRedactSelection: () => void;
enableMarqueeRedact: () => void;
isRedactActive: () => boolean;
endRedact: () => void;
// Common methods
@@ -211,19 +213,19 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({
}
}, []);
// Legacy UI actions for backwards compatibility
// In v2.5.0, both text selection and marquee use the same unified mode
// These just activate the unified redact mode and set the active type for UI state
// Activate a specific manual-redaction sub-mode. The plugin (v2.14.1) has
// dedicated text-selection and marquee modes; the optimistic setActiveType is
// reconciled by RedactionAPIBridge syncing the real state from EmbedPDF.
const activateTextSelection = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.enableRedact();
redactionApiRef.current.enableRedactSelection();
setActiveType("redactSelection" as RedactionMode);
}
}, [setActiveType]);
const activateMarquee = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.enableRedact();
redactionApiRef.current.enableMarqueeRedact();
setActiveType("marqueeRedact" as RedactionMode);
}
}, [setActiveType]);