mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282f18705d |
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user