diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 5b22c30644..c689e24f9e 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -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]
diff --git a/frontend/editor/src/core/components/tools/ToolPanel.tsx b/frontend/editor/src/core/components/tools/ToolPanel.tsx
index 2d6006a8eb..12f4d881a9 100644
--- a/frontend/editor/src/core/components/tools/ToolPanel.tsx
+++ b/frontend/editor/src/core/components/tools/ToolPanel.tsx
@@ -66,7 +66,18 @@ export default function ToolPanel({
) : (
-
+
{selectedToolKey ? (
{
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 (
<>
@@ -138,6 +163,54 @@ export default function ManualRedactionControls({
)}
+
+
+ }
+ 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")}
+
+
+
+ }
+ 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")}
+
+
+
{
onCollapsedClick?: () => void;
}
-function ReviewStepContent({
+export function ReviewStepContent({
operation,
onFileClick,
onUndo,
diff --git a/frontend/editor/src/core/components/tools/shared/ToolStepWizard.css b/frontend/editor/src/core/components/tools/shared/ToolStepWizard.css
new file mode 100644
index 0000000000..3f1c075473
--- /dev/null
+++ b/frontend/editor/src/core/components/tools/shared/ToolStepWizard.css
@@ -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;
+ }
+}
diff --git a/frontend/editor/src/core/components/tools/shared/ToolStepWizard.tsx b/frontend/editor/src/core/components/tools/shared/ToolStepWizard.tsx
new file mode 100644
index 0000000000..c74f0e6bcf
--- /dev/null
+++ b/frontend/editor/src/core/components/tools/shared/ToolStepWizard.tsx
@@ -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(() => 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 (
+