Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282f18705d |
@@ -4,6 +4,10 @@ apply = "Apply"
|
|||||||
applyAndContinue = "Save & Leave"
|
applyAndContinue = "Save & Leave"
|
||||||
areYouSure = "Are you sure you want to leave?"
|
areYouSure = "Are you sure you want to leave?"
|
||||||
back = "Back"
|
back = "Back"
|
||||||
|
wizardContinue = "Continue"
|
||||||
|
wizardProgress = "Step progress"
|
||||||
|
wizardStepProgress = "Step {{current}} of {{total}}"
|
||||||
|
wizardCompleteStep = "Complete this step to continue."
|
||||||
black = "Black"
|
black = "Black"
|
||||||
blue = "Blue"
|
blue = "Blue"
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
@@ -3871,6 +3875,9 @@ title = "File to PDF"
|
|||||||
|
|
||||||
[fileUpload]
|
[fileUpload]
|
||||||
dropFilesHere = "Drop files here or click the upload button"
|
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"
|
dropFilesHereOpen = "Drop files here or click the open button"
|
||||||
filesAvailable = "files available"
|
filesAvailable = "files available"
|
||||||
loadFromStorage = "Load from Storage"
|
loadFromStorage = "Load from Storage"
|
||||||
@@ -6242,6 +6249,8 @@ applyWarning = "⚠️ Permanent application, cannot be undone and the data unde
|
|||||||
colorLabel = "Redaction Color"
|
colorLabel = "Redaction Color"
|
||||||
controlsTitle = "Manual Redaction Controls"
|
controlsTitle = "Manual Redaction Controls"
|
||||||
instructions = "Select text or draw areas on the PDF to mark content for redaction."
|
instructions = "Select text or draw areas on the PDF to mark content for redaction."
|
||||||
|
markArea = "Mark Area"
|
||||||
|
markText = "Mark Text"
|
||||||
title = "Redaction Tools"
|
title = "Redaction Tools"
|
||||||
|
|
||||||
[redact.modeSelector]
|
[redact.modeSelector]
|
||||||
|
|||||||
@@ -66,7 +66,18 @@ export default function ToolPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<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 ? (
|
{selectedToolKey ? (
|
||||||
<ToolRenderer
|
<ToolRenderer
|
||||||
selectedToolKey={selectedToolKey}
|
selectedToolKey={selectedToolKey}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useRef, useCallback } from "react";
|
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 { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useSignature } from "@app/contexts/SignatureContext";
|
import { useSignature } from "@app/contexts/SignatureContext";
|
||||||
@@ -21,7 +23,8 @@ export default function ManualRedactionControls({
|
|||||||
|
|
||||||
// Use our RedactionContext which bridges to EmbedPDF
|
// Use our RedactionContext which bridges to EmbedPDF
|
||||||
const {
|
const {
|
||||||
activateManualRedact,
|
activateTextSelection,
|
||||||
|
activateMarquee,
|
||||||
redactionsApplied,
|
redactionsApplied,
|
||||||
setActiveType,
|
setActiveType,
|
||||||
setManualRedactColor,
|
setManualRedactColor,
|
||||||
@@ -50,9 +53,9 @@ export default function ManualRedactionControls({
|
|||||||
// Guard: pause auto-reactivation during save/export to avoid interfering with EmbedPDF
|
// Guard: pause auto-reactivation during save/export to avoid interfering with EmbedPDF
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
|
|
||||||
// Keep redaction tool active at all times while this component is mounted.
|
// Keep a redaction tool active while this component is mounted. If anything
|
||||||
// If anything deactivates it (annotation tools, text selection, file switch, etc.)
|
// deactivates it (annotation tools, file switch, etc.) this re-enables it —
|
||||||
// this re-enables it automatically — no manual "Activate" button needed.
|
// defaulting to text selection so "Mark Text" is the initial active mode.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
disabled ||
|
disabled ||
|
||||||
@@ -77,7 +80,7 @@ export default function ManualRedactionControls({
|
|||||||
// Small delay to avoid racing with EmbedPDF's own state updates
|
// Small delay to avoid racing with EmbedPDF's own state updates
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (!isSavingRef.current) {
|
if (!isSavingRef.current) {
|
||||||
activateManualRedact();
|
activateTextSelection();
|
||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
@@ -90,7 +93,7 @@ export default function ManualRedactionControls({
|
|||||||
showNavigationWarning,
|
showNavigationWarning,
|
||||||
setAnnotationMode,
|
setAnnotationMode,
|
||||||
signatureApiRef,
|
signatureApiRef,
|
||||||
activateManualRedact,
|
activateTextSelection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Reset redaction tool when switching between files
|
// Reset redaction tool when switching between files
|
||||||
@@ -123,6 +126,28 @@ export default function ManualRedactionControls({
|
|||||||
|
|
||||||
const isApiReady = isBridgeReady;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
@@ -138,6 +163,54 @@ export default function ManualRedactionControls({
|
|||||||
)}
|
)}
|
||||||
</Text>
|
</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
|
<ColorInput
|
||||||
label={t("redact.manual.colorLabel", "Redaction Colour")}
|
label={t("redact.manual.colorLabel", "Redaction Colour")}
|
||||||
value={manualRedactColor}
|
value={manualRedactColor}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface ReviewToolStepProps<TParams = unknown> {
|
|||||||
onCollapsedClick?: () => void;
|
onCollapsedClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReviewStepContent<TParams = unknown>({
|
export function ReviewStepContent<TParams = unknown>({
|
||||||
operation,
|
operation,
|
||||||
onFileClick,
|
onFileClick,
|
||||||
onUndo,
|
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,
|
ToolStepProvider,
|
||||||
} from "@app/components/tools/shared/ToolStep";
|
} from "@app/components/tools/shared/ToolStep";
|
||||||
import { ScopedOperationButton } from "@app/components/tools/shared/ScopedOperationButton";
|
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 { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
|
||||||
import {
|
import {
|
||||||
ToolWorkflowTitle,
|
ToolWorkflowTitle,
|
||||||
@@ -13,6 +19,7 @@ import {
|
|||||||
import { StirlingFile } from "@app/types/fileContext";
|
import { StirlingFile } from "@app/types/fileContext";
|
||||||
import type { TooltipTip } from "@app/types/tips";
|
import type { TooltipTip } from "@app/types/tips";
|
||||||
import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes";
|
import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||||
|
import i18n from "@app/i18n";
|
||||||
|
|
||||||
export interface FilesStepConfig {
|
export interface FilesStepConfig {
|
||||||
selectedFiles: StirlingFile[];
|
selectedFiles: StirlingFile[];
|
||||||
@@ -27,6 +34,12 @@ export interface MiddleStepConfig {
|
|||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
onCollapsedClick?: () => void;
|
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;
|
content: React.ReactNode;
|
||||||
tooltip?: {
|
tooltip?: {
|
||||||
content?: React.ReactNode;
|
content?: React.ReactNode;
|
||||||
@@ -98,11 +111,141 @@ export interface ToolFlowConfig<TParams = unknown> {
|
|||||||
export function createToolFlow<TParams = unknown>(
|
export function createToolFlow<TParams = unknown>(
|
||||||
config: ToolFlowConfig<TParams>,
|
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 (
|
return (
|
||||||
<Stack gap="sm" p="sm">
|
<Stack gap="sm" p="sm">
|
||||||
{/* <Stack gap="sm" p="sm" h="100%" w="100%" style={{ overflow: 'auto' }}> */}
|
|
||||||
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
|
||||||
{config.title && <ToolWorkflowTitle {...config.title} />}
|
{config.title && <ToolWorkflowTitle {...config.title} />}
|
||||||
|
|
||||||
@@ -131,50 +274,19 @@ export function createToolFlow<TParams = unknown>(
|
|||||||
|
|
||||||
{/* Preview (outside steps, above execute button).
|
{/* Preview (outside steps, above execute button).
|
||||||
Hide when review is visible or when no files are selected. */}
|
Hide when review is visible or when no files are selected. */}
|
||||||
{!config.review.isVisible &&
|
{!config.review.isVisible && filesCount > 0 && config.preview}
|
||||||
(config.files.selectedFiles?.length ?? 0) > 0 &&
|
|
||||||
config.preview}
|
|
||||||
|
|
||||||
{/* Execute Button */}
|
{/* Execute Button */}
|
||||||
{config.executeButton &&
|
{(() => {
|
||||||
config.executeButton.isVisible !== false &&
|
const button = renderExecuteButton();
|
||||||
(() => {
|
if (!button) return null;
|
||||||
const eb = config.executeButton;
|
return (
|
||||||
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
|
<>
|
||||||
// Compute the disabled reason from structured fields; explicit disabledReason wins if set.
|
{button}
|
||||||
const effectiveDisabledReason: ExecuteDisabledReason =
|
{config.belowExecuteButton}
|
||||||
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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Review Step */}
|
{/* Review Step */}
|
||||||
{steps.createReviewStep({
|
{steps.createReviewStep({
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
|
|||||||
enableRedact: () => {
|
enableRedact: () => {
|
||||||
redactionProvides?.enableRedact();
|
redactionProvides?.enableRedact();
|
||||||
},
|
},
|
||||||
|
enableRedactSelection: () => {
|
||||||
|
redactionProvides?.enableRedactSelection();
|
||||||
|
},
|
||||||
|
enableMarqueeRedact: () => {
|
||||||
|
redactionProvides?.enableMarqueeRedact();
|
||||||
|
},
|
||||||
isRedactActive: () => {
|
isRedactActive: () => {
|
||||||
return redactionProvides?.isRedactActive() ?? false;
|
return redactionProvides?.isRedactActive() ?? false;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { RedactionMode } from "@embedpdf/plugin-redaction";
|
|||||||
export interface RedactionAPI {
|
export interface RedactionAPI {
|
||||||
toggleRedact: () => void;
|
toggleRedact: () => void;
|
||||||
enableRedact: () => void;
|
enableRedact: () => void;
|
||||||
|
enableRedactSelection: () => void;
|
||||||
|
enableMarqueeRedact: () => void;
|
||||||
isRedactActive: () => boolean;
|
isRedactActive: () => boolean;
|
||||||
endRedact: () => void;
|
endRedact: () => void;
|
||||||
// Common methods
|
// Common methods
|
||||||
@@ -211,19 +213,19 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Legacy UI actions for backwards compatibility
|
// Activate a specific manual-redaction sub-mode. The plugin (v2.14.1) has
|
||||||
// In v2.5.0, both text selection and marquee use the same unified mode
|
// dedicated text-selection and marquee modes; the optimistic setActiveType is
|
||||||
// These just activate the unified redact mode and set the active type for UI state
|
// reconciled by RedactionAPIBridge syncing the real state from EmbedPDF.
|
||||||
const activateTextSelection = useCallback(() => {
|
const activateTextSelection = useCallback(() => {
|
||||||
if (redactionApiRef.current) {
|
if (redactionApiRef.current) {
|
||||||
redactionApiRef.current.enableRedact();
|
redactionApiRef.current.enableRedactSelection();
|
||||||
setActiveType("redactSelection" as RedactionMode);
|
setActiveType("redactSelection" as RedactionMode);
|
||||||
}
|
}
|
||||||
}, [setActiveType]);
|
}, [setActiveType]);
|
||||||
|
|
||||||
const activateMarquee = useCallback(() => {
|
const activateMarquee = useCallback(() => {
|
||||||
if (redactionApiRef.current) {
|
if (redactionApiRef.current) {
|
||||||
redactionApiRef.current.enableRedact();
|
redactionApiRef.current.enableMarqueeRedact();
|
||||||
setActiveType("marqueeRedact" as RedactionMode);
|
setActiveType("marqueeRedact" as RedactionMode);
|
||||||
}
|
}
|
||||||
}, [setActiveType]);
|
}, [setActiveType]);
|
||||||
|
|||||||
Reference in New Issue
Block a user