feat: SOTA overhaul of automate pipeline page (#53)

* feat(find-duplicates): upgrade to 128-bit dHash with metadata and thumbnails

* feat(find-duplicates): add custom-results display mode and duplicate store

* feat(find-duplicates): add results overview grid and detail comparison view

* feat(find-duplicates): overhaul settings with sensitivity presets and download actions

* feat(find-duplicates): update i18n description

* chore: replace jsqr with zxing-wasm for barcode reading

* feat(barcode-read): rewrite backend with zxing-wasm for all barcode types

* feat(barcode-read): rewrite frontend with multi-file, results table, progress, export

- Multi-file sequential processing with per-file progress
- Structured results table with type badges and copy per-result
- Copy All and Export CSV functionality
- Thorough scan toggle (maps to tryHarder in zxing-wasm)
- Before/after view shows annotated image with bounding boxes
- Updated tool description in constants and i18n

* feat(stitch): update tool name and description for redesign

* feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes

* feat(stitch): redesign settings UI with grid, alignment, border, radius, quality

* test(stitch): add stitch to e2e tool navigation suite

* feat(vectorize): redesign with dual-engine backend and preset-driven UI

- Backend: potrace for B&W, VTracer (@neplex/vectorizer) for full-color vectorization
- Frontend: 5 presets (logo, illustration, photo, sketch, custom)
- Settings: color precision, gradient step, detail, smoothing, corner threshold, invert
- Updated OpenAPI spec and i18n description

* feat(border): redesign with presets, shadow, padding color, swatches

- Add 8 one-click presets (Clean White, Gallery Black, Shadow, Rounded, Polaroid, Vintage, Minimal, Cinematic)
- Implement proper shadow rendering with blur, offset X/Y, color, opacity
- Add padding color control (was hardcoded white)
- Add color swatches for quick color selection
- Wrap in form for Enter key submission
- Add smart validation (requires at least one effect active)
- Align frontend/backend slider ranges
- Organize UI with sections and collapsible shadow toggle

* feat(split): overhaul image splitting with live grid overlay and tile preview

- Add interactive-split display mode with SplitCanvas component
- Live SVG grid overlay on uploaded image showing split boundaries
- Two split modes: Grid (NxM) and Tile Size (px dimensions)
- 9 grid presets (2x1, 1x2, 2x2, 3x1, 1x3, 3x3, 2x3, 3x2, 4x4)
- Output format selection (original/PNG/JPG/WebP) with quality slider
- Post-split tile preview thumbnails with individual download
- Download All as ZIP button
- HEIC/HEIF preview with loading spinner
- Backend: tile-size mode, output format conversion, quality control
- Zustand store for split state management

* feat(split): rewrite backend and frontend settings

Backend: tile-size mode, output format conversion, quality control.
Frontend: split modes, presets, format selector, tile preview grid.

* feat(border): add live CSS preview and remove before/after slider

- Add imageWrapperStyle prop to ImageViewer for live border preview
- Add onImageStyle callback through tool-page to settings components
- Change border displayMode to no-comparison (no slider)
- BorderControls sends live CSS styles (border, padding, radius, shadow)
- Preview updates instantly as user adjusts sliders or clicks presets

* fix: repair i18n file corrupted by formatter during merge conflict resolution

* feat(border): enable live CSS preview in right pane as settings change

* fix(border): keep CSS preview visible after processing for WYSIWYG consistency

* chore: add @dnd-kit/core and @dnd-kit/sortable for pipeline drag-and-drop

* feat(pipeline): add Zustand store for pipeline step management

* feat(automate): add pipeline step settings summary utility with tests

* feat(automate): add POST /api/v1/pipeline/batch for multi-file pipeline execution

* feat(automate): add usePipelineProcessor hook for single and batch pipeline execution

* fix(automate): pass settings prop to all pipeline step controls for state restoration

* feat(automate): rewrite pipeline builder with dnd-kit drag-and-drop and compact step cards

* feat(automate): rewrite page with two-panel layout, image preview, and batch support

* test(automate): update e2e tests for new two-panel pipeline layout

---------

Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
This commit is contained in:
stirling-image
2026-04-13 16:26:38 +08:00
committed by GitHub
co-authored by Siddharth Kumar Sah
parent a1e11dff74
commit fb33a46a64
28 changed files with 1935 additions and 714 deletions
@@ -5,13 +5,23 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export interface BlurFacesControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function BlurFacesControls({ onChange }: BlurFacesControlsProps) {
export function BlurFacesControls({ settings: initialSettings, onChange }: BlurFacesControlsProps) {
const [blurRadius, setBlurRadius] = useState(30);
const [sensitivity, setSensitivity] = useState(50);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius));
if (initialSettings.sensitivity != null)
setSensitivity(Number(initialSettings.sensitivity) * 100);
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -215,11 +215,16 @@ function buildPreviewStyle(s: {
// ── Controls ─────────────────────────────────────────────────────────
export interface BorderControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
onImageStyle?: (style: React.CSSProperties | null) => void;
}
export function BorderControls({ onChange, onImageStyle }: BorderControlsProps) {
export function BorderControls({
settings: initialSettings,
onChange,
onImageStyle,
}: BorderControlsProps) {
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [borderWidth, setBorderWidth] = useState(10);
const [borderColor, setBorderColor] = useState("#000000");
@@ -233,6 +238,26 @@ export function BorderControls({ onChange, onImageStyle }: BorderControlsProps)
const [shadowColor, setShadowColor] = useState("#000000");
const [shadowOpacity, setShadowOpacity] = useState(40);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.borderWidth != null) setBorderWidth(Number(initialSettings.borderWidth));
if (initialSettings.borderColor != null) setBorderColor(String(initialSettings.borderColor));
if (initialSettings.padding != null) setPadding(Number(initialSettings.padding));
if (initialSettings.paddingColor != null) setPaddingColor(String(initialSettings.paddingColor));
if (initialSettings.cornerRadius != null) setCornerRadius(Number(initialSettings.cornerRadius));
if (initialSettings.shadow != null) setShadow(Boolean(initialSettings.shadow));
if (initialSettings.shadowBlur != null) setShadowBlur(Number(initialSettings.shadowBlur));
if (initialSettings.shadowOffsetX != null)
setShadowOffsetX(Number(initialSettings.shadowOffsetX));
if (initialSettings.shadowOffsetY != null)
setShadowOffsetY(Number(initialSettings.shadowOffsetY));
if (initialSettings.shadowColor != null) setShadowColor(String(initialSettings.shadowColor));
if (initialSettings.shadowOpacity != null)
setShadowOpacity(Number(initialSettings.shadowOpacity));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -8,11 +8,17 @@ type Effect = "none" | "grayscale" | "sepia" | "invert";
interface ColorControlsProps {
toolId: string;
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
onPreviewFilter?: (filter: string) => void;
}
export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorControlsProps) {
export function ColorControls({
toolId,
settings: initialSettings,
onChange,
onPreviewFilter,
}: ColorControlsProps) {
// Light
const [brightness, setBrightness] = useState(0);
const [contrast, setContrast] = useState(0);
@@ -36,6 +42,24 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
// Effects
const [effect, setEffect] = useState<Effect>("none");
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.brightness != null) setBrightness(Number(initialSettings.brightness));
if (initialSettings.contrast != null) setContrast(Number(initialSettings.contrast));
if (initialSettings.exposure != null) setExposure(Number(initialSettings.exposure));
if (initialSettings.saturation != null) setSaturation(Number(initialSettings.saturation));
if (initialSettings.temperature != null) setTemperature(Number(initialSettings.temperature));
if (initialSettings.tint != null) setTint(Number(initialSettings.tint));
if (initialSettings.hue != null) setHue(Number(initialSettings.hue));
if (initialSettings.sharpness != null) setSharpness(Number(initialSettings.sharpness));
if (initialSettings.red != null) setRed(Number(initialSettings.red));
if (initialSettings.green != null) setGreen(Number(initialSettings.green));
if (initialSettings.blue != null) setBlue(Number(initialSettings.blue));
if (initialSettings.effect != null) setEffect(initialSettings.effect as Effect);
}, [initialSettings]);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
@@ -7,14 +7,24 @@ import { useFileStore } from "@/stores/file-store";
type CompressMode = "quality" | "targetSize";
export interface CompressControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function CompressControls({ onChange }: CompressControlsProps) {
export function CompressControls({ settings: initialSettings, onChange }: CompressControlsProps) {
const [mode, setMode] = useState<CompressMode>("quality");
const [quality, setQuality] = useState(75);
const [targetSizeKb, setTargetSizeKb] = useState("");
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.mode != null) setMode(initialSettings.mode as CompressMode);
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
if (initialSettings.targetSizeKb != null) setTargetSizeKb(String(initialSettings.targetSizeKb));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -8,13 +8,22 @@ const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "he
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
export interface ConvertControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function ConvertControls({ onChange }: ConvertControlsProps) {
export function ConvertControls({ settings: initialSettings, onChange }: ConvertControlsProps) {
const [format, setFormat] = useState<string>("png");
const [quality, setQuality] = useState(85);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.format != null) setFormat(String(initialSettings.format));
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
}, [initialSettings]);
const isLossy = LOSSY_FORMATS.includes(format);
const onChangeRef = useRef(onChange);
@@ -402,15 +402,26 @@ export function CropSettings({
// ── Pipeline-only crop controls (numeric inputs, no canvas) ──────────
export interface CropControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function CropControls({ onChange }: CropControlsProps) {
export function CropControls({ settings: initialSettings, onChange }: CropControlsProps) {
const [left, setLeft] = useState(0);
const [top, setTop] = useState(0);
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.left != null) setLeft(Number(initialSettings.left));
if (initialSettings.top != null) setTop(Number(initialSettings.top));
if (initialSettings.width != null) setWidth(String(initialSettings.width));
if (initialSettings.height != null) setHeight(String(initialSettings.height));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -20,10 +20,11 @@ const MODES: { id: GifMode; label: string; requiresAnimation: boolean }[] = [
];
export interface GifToolsControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function GifToolsControls({ onChange }: GifToolsControlsProps) {
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
const { info, loading: infoLoading } = useGifInfo();
const isAnimated = (info?.pages ?? 0) > 1;
@@ -65,6 +66,17 @@ export function GifToolsControls({ onChange }: GifToolsControlsProps) {
const [loopMode, setLoopMode] = useState<LoopMode>("infinite");
const [loopCount, setLoopCount] = useState("2");
// Initialize from saved pipeline settings
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.mode != null) setMode(initialSettings.mode as GifMode);
if (initialSettings.width != null) setWidth(String(initialSettings.width));
if (initialSettings.height != null) setHeight(String(initialSettings.height));
if (initialSettings.percentage != null) setPercentage(String(initialSettings.percentage));
}, [initialSettings]);
// Initialize loop from metadata
useEffect(() => {
if (info) {
+193 -351
View File
@@ -1,72 +1,179 @@
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { TOOLS } from "@stirling-image/shared";
import * as icons from "lucide-react";
import {
ChevronDown,
ChevronRight,
ChevronUp,
Download,
FileImage,
Loader2,
Play,
Plus,
Save,
Upload,
X,
} from "lucide-react";
import { type SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { GripVertical, Plus, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { SearchBar } from "@/components/common/search-bar";
import { apiGet } from "@/lib/api";
import { cn, generateId } from "@/lib/utils";
import { cn } from "@/lib/utils";
import type { PipelineStep } from "@/stores/pipeline-store";
import { PipelineStepSettings } from "./pipeline-step-settings";
import { getSettingsSummary } from "./pipeline-step-summary";
/** Tools that can be used as pipeline steps (excludes pipeline/batch/multi-file tools). */
const PIPELINE_TOOLS_BASE = TOOLS.filter(
(t) => !["pipeline", "batch", "compare", "find-duplicates", "collage", "compose"].includes(t.id),
);
export interface PipelineStep {
id: string;
toolId: string;
settings: Record<string, unknown>;
}
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
interface PipelineBuilderProps {
steps: PipelineStep[];
onStepsChange: (action: SetStateAction<PipelineStep[]>) => void;
onSave: (name: string, description: string) => void;
onExecute: (file: File) => void;
saving?: boolean;
executing?: boolean;
executionResult?: {
downloadUrl: string;
originalSize: number;
processedSize: number;
stepsCompleted: number;
} | null;
executionError?: string | null;
expandedStepId: string | null;
onAddStep: (toolId: string) => void;
onRemoveStep: (id: string) => void;
onReorderSteps: (activeId: string, overId: string) => void;
onUpdateSettings: (id: string, settings: Record<string, unknown>) => void;
onToggleStep: (id: string | null) => void;
}
/* ------------------------------------------------------------------ */
/* SortableStep */
/* ------------------------------------------------------------------ */
interface SortableStepProps {
step: PipelineStep;
index: number;
isExpanded: boolean;
onToggle: () => void;
onRemove: () => void;
onUpdateSettings: (settings: Record<string, unknown>) => void;
}
function SortableStep({
step,
index,
isExpanded,
onToggle,
onRemove,
onUpdateSettings,
}: SortableStepProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: step.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const tool = TOOLS.find((t) => t.id === step.toolId);
if (!tool) return null;
const Icon = iconsMap[tool.icon] || icons.FileImage;
const summary = getSettingsSummary(step.toolId, step.settings);
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"rounded-lg border bg-background overflow-hidden transition-colors",
isDragging && "opacity-50",
isExpanded ? "border-primary" : "border-border",
)}
>
{/* Header row - click to expand/collapse */}
<button
type="button"
onClick={onToggle}
className="flex items-center gap-2 p-3 w-full text-left"
>
{/* Drag handle */}
<span
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-0.5 rounded hover:bg-muted text-muted-foreground"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</span>
{/* Step number badge */}
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
{index + 1}
</span>
{/* Tool icon + name */}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium text-foreground">{tool.name}</span>
{/* Settings summary when collapsed */}
{!isExpanded && summary && (
<span className="text-xs text-muted-foreground truncate ml-1">{summary}</span>
)}
<span className="flex-1" />
{/* Remove button */}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onRemove();
}
}}
title="Remove"
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
>
<X className="h-4 w-4" />
</span>
</button>
{/* Inline settings panel */}
<div className={isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"}>
<PipelineStepSettings
toolId={step.toolId}
settings={step.settings}
onChange={onUpdateSettings}
/>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* PipelineBuilder */
/* ------------------------------------------------------------------ */
export function PipelineBuilder({
steps,
onStepsChange,
onSave,
onExecute,
saving = false,
executing = false,
executionResult = null,
executionError = null,
expandedStepId,
onAddStep,
onRemoveStep,
onReorderSteps,
onUpdateSettings,
onToggleStep,
}: PipelineBuilderProps) {
const [showToolPicker, setShowToolPicker] = useState(false);
const [expandedStep, setExpandedStep] = useState<string | null>(null);
const [saveName, setSaveName] = useState("");
const [saveDescription, setSaveDescription] = useState("");
const [showSaveForm, setShowSaveForm] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [toolSearch, setToolSearch] = useState("");
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [experimentalEnabled, setExperimentalEnabled] = useState(false);
const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null);
const [toolSearch, setToolSearch] = useState("");
/* Fetch settings + pipeline-compatible tool IDs on mount */
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => {
@@ -77,7 +184,6 @@ export function PipelineBuilder({
})
.catch(() => {});
// Fetch which tools actually support pipeline execution
apiGet<{ toolIds: string[] }>("/v1/pipeline/tools")
.then((data) => setPipelineToolIds(data.toolIds))
.catch(() => {});
@@ -88,9 +194,7 @@ export function PipelineBuilder({
return PIPELINE_TOOLS_BASE.filter((t) => {
if (disabledTools.includes(t.id)) return false;
if (t.experimental && !experimentalEnabled) return false;
// Only show tools that are registered in the pipeline-compatible tool registry
if (pipelineToolIds && !pipelineToolIds.includes(t.id)) return false;
// Search filter
if (q && !t.name.toLowerCase().includes(q) && !t.description.toLowerCase().includes(q)) {
return false;
}
@@ -98,215 +202,53 @@ export function PipelineBuilder({
});
}, [disabledTools, experimentalEnabled, pipelineToolIds, toolSearch]);
const addStep = useCallback(
(toolId: string) => {
const step: PipelineStep = {
id: generateId(),
toolId,
settings: {},
};
onStepsChange((prev) => [...prev, step]);
setShowToolPicker(false);
setToolSearch("");
setExpandedStep(step.id);
},
[onStepsChange],
/* dnd-kit sensors */
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const removeStep = useCallback(
(id: string) => {
onStepsChange((prev) => prev.filter((s) => s.id !== id));
setExpandedStep((prev) => (prev === id ? null : prev));
},
[onStepsChange],
);
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
onReorderSteps(String(active.id), String(over.id));
}
}
const moveStep = useCallback(
(id: string, direction: "up" | "down") => {
onStepsChange((prev) => {
const idx = prev.findIndex((s) => s.id === id);
if (idx < 0) return prev;
const newIdx = direction === "up" ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= prev.length) return prev;
const newSteps = [...prev];
[newSteps[idx], newSteps[newIdx]] = [newSteps[newIdx], newSteps[idx]];
return newSteps;
});
},
[onStepsChange],
);
const updateStepSettings = useCallback(
(id: string, newSettings: Record<string, unknown>) => {
onStepsChange((prev) => prev.map((s) => (s.id === id ? { ...s, settings: newSettings } : s)));
},
[onStepsChange],
);
const handleFileSelect = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,.heic,.heif,.hif";
input.onchange = (e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) setFile(f);
};
input.click();
}, []);
const handleFileDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) setFile(f);
}, []);
const handleSave = useCallback(() => {
if (!saveName.trim()) return;
onSave(saveName.trim(), saveDescription.trim());
setSaveName("");
setSaveDescription("");
setShowSaveForm(false);
}, [saveName, saveDescription, onSave]);
const handleExecute = useCallback(() => {
if (!file) return;
onExecute(file);
}, [file, onExecute]);
const iconsMap = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>;
function handleAddStep(toolId: string) {
onAddStep(toolId);
setShowToolPicker(false);
setToolSearch("");
}
return (
<div className="space-y-6">
{/* File Upload Area */}
<section
aria-label="File upload area"
onDragOver={(e) => e.preventDefault()}
onDrop={handleFileDrop}
className={cn(
"rounded-xl border-2 border-dashed p-6 text-center transition-colors",
file
? "border-primary/30 bg-primary/5"
: "border-border bg-muted/20 hover:border-primary/30",
)}
>
{file ? (
<div className="flex items-center justify-center gap-3">
<FileImage className="h-5 w-5 text-primary" />
<div className="text-sm">
<span className="font-medium text-foreground">{file.name}</span>
<span className="text-muted-foreground ml-2">
({(file.size / 1024).toFixed(0)} KB)
</span>
<div className="space-y-2">
{/* Sortable step list */}
{steps.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
Add steps to build your pipeline
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={steps.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{steps.map((step, idx) => (
<SortableStep
key={step.id}
step={step}
index={idx}
isExpanded={expandedStepId === step.id}
onToggle={() => onToggleStep(expandedStepId === step.id ? null : step.id)}
onRemove={() => onRemoveStep(step.id)}
onUpdateSettings={(s) => onUpdateSettings(step.id, s)}
/>
))}
</div>
<button
type="button"
onClick={() => setFile(null)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<button
type="button"
onClick={handleFileSelect}
className="flex items-center gap-2 mx-auto px-4 py-2 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm"
>
<Upload className="h-4 w-4" />
Upload image to process
</button>
)}
</section>
</SortableContext>
</DndContext>
)}
{/* Pipeline Steps */}
<div className="space-y-2">
{steps.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
Add steps to build your automation pipeline
</div>
) : (
steps.map((step, idx) => {
const tool = TOOLS.find((t) => t.id === step.toolId);
if (!tool) return null;
const Icon = iconsMap[tool.icon] || icons.FileImage;
const isExpanded = expandedStep === step.id;
return (
<div
key={step.id}
className="rounded-lg border border-border bg-background overflow-hidden"
>
<div className="flex items-center gap-2 p-3">
{/* Step number */}
<span className="w-6 h-6 rounded-full bg-primary/10 text-primary text-xs font-semibold flex items-center justify-center shrink-0">
{idx + 1}
</span>
{/* Tool icon + name */}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium text-foreground flex-1">{tool.name}</span>
{/* Controls */}
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
title="Settings"
>
<ChevronRight
className={cn("h-4 w-4 transition-transform", isExpanded && "rotate-90")}
/>
</button>
<button
type="button"
onClick={() => moveStep(step.id, "up")}
disabled={idx === 0}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
title="Move up"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => moveStep(step.id, "down")}
disabled={idx === steps.length - 1}
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-30"
title="Move down"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => removeStep(step.id)}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Remove"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Settings panel - hidden when collapsed, never unmounted so state persists */}
<div
className={
isExpanded ? "border-t border-border p-3 bg-muted/10 space-y-3" : "hidden"
}
>
<p className="text-xs text-muted-foreground">{tool.description}</p>
<PipelineStepSettings
toolId={step.toolId}
settings={step.settings}
onChange={(s) => updateStepSettings(step.id, s)}
/>
</div>
</div>
);
})
)}
</div>
{/* Add Step */}
{/* Tool picker */}
{showToolPicker ? (
<div className="rounded-lg border border-border bg-background p-3 space-y-2 max-h-80 overflow-y-auto">
<div className="flex items-center justify-between mb-2">
@@ -332,7 +274,7 @@ export function PipelineBuilder({
<button
key={tool.id}
type="button"
onClick={() => addStep(tool.id)}
onClick={() => handleAddStep(tool.id)}
className="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-muted text-sm text-left transition-colors"
>
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
@@ -358,106 +300,6 @@ export function PipelineBuilder({
Add Step
</button>
)}
{/* Execution error */}
{executionError && (
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<icons.AlertCircle className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">{executionError}</span>
</div>
</div>
)}
{/* Execution result */}
{executionResult && (
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-4 space-y-2">
<div className="flex items-center gap-2 text-green-700 dark:text-green-400">
<icons.CheckCircle2 className="h-5 w-5" />
<span className="font-medium text-sm">
Pipeline completed ({executionResult.stepsCompleted} steps)
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>Original: {(executionResult.originalSize / 1024).toFixed(0)} KB</span>
<span>Processed: {(executionResult.processedSize / 1024).toFixed(0)} KB</span>
</div>
<a
href={executionResult.downloadUrl}
download
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Download className="h-4 w-4" />
Download Result
</a>
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleExecute}
disabled={steps.length === 0 || !file || executing}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{executing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<Play className="h-4 w-4" />
Process
</>
)}
</button>
{!showSaveForm ? (
<button
type="button"
onClick={() => setShowSaveForm(true)}
disabled={steps.length === 0}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-border text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="h-4 w-4" />
Save Pipeline
</button>
) : (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={saveName}
onChange={(e) => setSaveName(e.target.value)}
placeholder="Pipeline name"
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1"
/>
<input
type="text"
value={saveDescription}
onChange={(e) => setSaveDescription(e.target.value)}
placeholder="Description (optional)"
className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground flex-1 hidden sm:block"
/>
<button
type="button"
onClick={handleSave}
disabled={!saveName.trim() || saving}
className="px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
onClick={() => setShowSaveForm(false)}
className="p-2 rounded-lg hover:bg-muted text-muted-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
);
}
@@ -24,23 +24,28 @@ interface PipelineStepSettingsProps {
}
export function PipelineStepSettings({ toolId, settings, onChange }: PipelineStepSettingsProps) {
if (toolId === "resize") return <ResizeControls onChange={onChange} />;
if (toolId === "crop") return <CropControls onChange={onChange} />;
if (toolId === "rotate") return <RotateControls onChange={onChange} />;
if (toolId === "convert") return <ConvertControls onChange={onChange} />;
if (toolId === "compress") return <CompressControls onChange={onChange} />;
if (toolId === "strip-metadata") return <StripMetadataControls onChange={onChange} />;
if (toolId === "border") return <BorderControls onChange={onChange} />;
if (toolId === "watermark-text") return <WatermarkTextControls onChange={onChange} />;
if (toolId === "text-overlay") return <TextOverlayControls onChange={onChange} />;
if (toolId === "replace-color") return <ReplaceColorControls onChange={onChange} />;
if (toolId === "smart-crop") return <SmartCropControls onChange={onChange} />;
if (toolId === "gif-tools") return <GifToolsControls onChange={onChange} />;
if (toolId === "upscale") return <UpscaleControls onChange={onChange} />;
if (toolId === "blur-faces") return <BlurFacesControls onChange={onChange} />;
if (toolId === "resize") return <ResizeControls settings={settings} onChange={onChange} />;
if (toolId === "crop") return <CropControls settings={settings} onChange={onChange} />;
if (toolId === "rotate") return <RotateControls settings={settings} onChange={onChange} />;
if (toolId === "convert") return <ConvertControls settings={settings} onChange={onChange} />;
if (toolId === "compress") return <CompressControls settings={settings} onChange={onChange} />;
if (toolId === "strip-metadata")
return <StripMetadataControls settings={settings} onChange={onChange} />;
if (toolId === "border") return <BorderControls settings={settings} onChange={onChange} />;
if (toolId === "watermark-text")
return <WatermarkTextControls settings={settings} onChange={onChange} />;
if (toolId === "text-overlay")
return <TextOverlayControls settings={settings} onChange={onChange} />;
if (toolId === "replace-color")
return <ReplaceColorControls settings={settings} onChange={onChange} />;
if (toolId === "smart-crop") return <SmartCropControls settings={settings} onChange={onChange} />;
if (toolId === "gif-tools") return <GifToolsControls settings={settings} onChange={onChange} />;
if (toolId === "upscale") return <UpscaleControls settings={settings} onChange={onChange} />;
if (toolId === "blur-faces") return <BlurFacesControls settings={settings} onChange={onChange} />;
if (toolId === "remove-background")
return <RemoveBgControls settings={settings} onChange={onChange} />;
if (COLOR_TOOL_IDS.has(toolId)) return <ColorControls toolId={toolId} onChange={onChange} />;
if (COLOR_TOOL_IDS.has(toolId))
return <ColorControls toolId={toolId} settings={settings} onChange={onChange} />;
return (
<p className="text-xs text-muted-foreground italic">
@@ -0,0 +1,59 @@
export function getSettingsSummary(toolId: string, settings: Record<string, unknown>): string {
switch (toolId) {
case "resize": {
if (settings.percentage) return `${settings.percentage}%`;
if (settings.width && settings.height) return `${settings.width} x ${settings.height}`;
if (settings.width) return `${settings.width}px wide`;
if (settings.height) return `${settings.height}px tall`;
return "";
}
case "compress": {
if (settings.mode === "targetSize" && settings.targetSizeKb)
return `Target ${settings.targetSizeKb} KB`;
if (settings.quality != null) return `Quality ${settings.quality}`;
return "";
}
case "convert": {
if (settings.format) return String(settings.format).toUpperCase();
return "";
}
case "rotate": {
if (settings.angle != null) return `${settings.angle}\u00B0`;
return "";
}
case "watermark-text": {
if (settings.text) {
const t = String(settings.text);
return t.length > 25 ? `${t.slice(0, 24)}...` : t;
}
return "";
}
case "text-overlay": {
if (settings.text) {
const t = String(settings.text);
return t.length > 25 ? `${t.slice(0, 24)}...` : t;
}
return "";
}
case "crop": {
if (settings.width && settings.height) return `${settings.width} x ${settings.height}`;
return "";
}
case "border": {
if (settings.width) return `${settings.width}px border`;
return "";
}
case "blur-faces":
return "Blur faces";
case "remove-background":
return "Remove BG";
case "strip-metadata":
return "Strip EXIF";
case "upscale": {
if (settings.scale) return `${settings.scale}x`;
return "";
}
default:
return "";
}
}
@@ -5,15 +5,30 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export interface ReplaceColorControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function ReplaceColorControls({ onChange }: ReplaceColorControlsProps) {
export function ReplaceColorControls({
settings: initialSettings,
onChange,
}: ReplaceColorControlsProps) {
const [sourceColor, setSourceColor] = useState("#FF0000");
const [targetColor, setTargetColor] = useState("#00FF00");
const [makeTransparent, setMakeTransparent] = useState(false);
const [tolerance, setTolerance] = useState(30);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.sourceColor != null) setSourceColor(String(initialSettings.sourceColor));
if (initialSettings.targetColor != null) setTargetColor(String(initialSettings.targetColor));
if (initialSettings.makeTransparent != null)
setMakeTransparent(Boolean(initialSettings.makeTransparent));
if (initialSettings.tolerance != null) setTolerance(Number(initialSettings.tolerance));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -29,10 +29,11 @@ function HintIcon({ text }: { text: string }) {
}
export interface ResizeControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function ResizeControls({ onChange }: ResizeControlsProps) {
export function ResizeControls({ settings: initialSettings, onChange }: ResizeControlsProps) {
const [tab, setTab] = useState<ResizeTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
const [width, setWidth] = useState<string>("");
@@ -47,6 +48,29 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
const [sobelThreshold, setSobelThreshold] = useState(2);
const [squareMode, setSquareMode] = useState(false);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.width != null) setWidth(String(initialSettings.width));
if (initialSettings.height != null) setHeight(String(initialSettings.height));
if (initialSettings.percentage != null) setPercentage(String(initialSettings.percentage));
if (initialSettings.fit != null) setFit(initialSettings.fit as FitMode);
if (initialSettings.withoutEnlargement != null)
setWithoutEnlargement(Boolean(initialSettings.withoutEnlargement));
if (initialSettings.contentAware != null)
setContentAware(Boolean(initialSettings.contentAware));
if (initialSettings.protectFaces != null)
setProtectFaces(Boolean(initialSettings.protectFaces));
if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius));
if (initialSettings.sobelThreshold != null)
setSobelThreshold(Number(initialSettings.sobelThreshold));
if (initialSettings.square != null) setSquareMode(Boolean(initialSettings.square));
// Infer tab from settings
if (initialSettings.percentage != null) setTab("scale");
else if (initialSettings.contentAware) setTab("custom");
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -11,12 +11,18 @@ export interface PreviewTransform {
}
export interface RotateControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
onPreviewTransform?: (transform: PreviewTransform) => void;
resetSignal?: number;
}
export function RotateControls({ onChange, onPreviewTransform, resetSignal }: RotateControlsProps) {
export function RotateControls({
settings: initialSettings,
onChange,
onPreviewTransform,
resetSignal,
}: RotateControlsProps) {
// Quick rotation in 90° steps: 0, 90, 180, 270
const [rotation, setRotation] = useState(0);
// Fine straighten adjustment: -45 to +45
@@ -24,6 +30,15 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.angle != null) setRotation(Number(initialSettings.angle));
if (initialSettings.horizontal != null) setFlipH(Boolean(initialSettings.horizontal));
if (initialSettings.vertical != null) setFlipV(Boolean(initialSettings.vertical));
}, [initialSettings]);
const totalAngle = rotation + straighten;
const onChangeRef = useRef(onChange);
@@ -31,10 +31,11 @@ function HintIcon({ text }: { text: string }) {
}
export interface SmartCropControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function SmartCropControls({ onChange }: SmartCropControlsProps) {
export function SmartCropControls({ settings: initialSettings, onChange }: SmartCropControlsProps) {
const [mode, setMode] = useState<CropMode>("subject");
const [subjectTab, setSubjectTab] = useState<SubjectTab>("custom");
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
@@ -60,6 +61,26 @@ export function SmartCropControls({ onChange }: SmartCropControlsProps) {
// Shared
const [quality, setQuality] = useState(95);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.mode != null) setMode(initialSettings.mode as CropMode);
if (initialSettings.strategy != null)
setStrategy(initialSettings.strategy as "attention" | "entropy");
if (initialSettings.facePreset != null) setFacePreset(String(initialSettings.facePreset));
if (initialSettings.sensitivity != null)
setSensitivity(Number(initialSettings.sensitivity) * 100);
if (initialSettings.width != null) setWidth(String(initialSettings.width));
if (initialSettings.height != null) setHeight(String(initialSettings.height));
if (initialSettings.padding != null) setPadding(Number(initialSettings.padding));
if (initialSettings.threshold != null) setThreshold(Number(initialSettings.threshold));
if (initialSettings.padToSquare != null) setPadToSquare(Boolean(initialSettings.padToSquare));
if (initialSettings.padColor != null) setPadColor(String(initialSettings.padColor));
if (initialSettings.targetSize != null) setTargetSize(String(initialSettings.targetSize));
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -62,6 +62,7 @@ interface MetadataResult {
}
interface StripMetadataControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
/** Passed from parent to preserve field-count badges in checkbox labels */
metadata?: MetadataResult | null;
@@ -70,6 +71,7 @@ interface StripMetadataControlsProps {
}
export function StripMetadataControls({
settings: initialSettings,
onChange,
metadata,
hasExif,
@@ -81,6 +83,17 @@ export function StripMetadataControls({
const [stripIcc, setStripIcc] = useState(false);
const [stripXmp, setStripXmp] = useState(false);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.stripAll != null) setStripAll(Boolean(initialSettings.stripAll));
if (initialSettings.stripExif != null) setStripExif(Boolean(initialSettings.stripExif));
if (initialSettings.stripGps != null) setStripGps(Boolean(initialSettings.stripGps));
if (initialSettings.stripIcc != null) setStripIcc(Boolean(initialSettings.stripIcc));
if (initialSettings.stripXmp != null) setStripXmp(Boolean(initialSettings.stripXmp));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
@@ -5,10 +5,14 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export interface TextOverlayControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function TextOverlayControls({ onChange }: TextOverlayControlsProps) {
export function TextOverlayControls({
settings: initialSettings,
onChange,
}: TextOverlayControlsProps) {
const [text, setText] = useState("Your Text Here");
const [fontSize, setFontSize] = useState(48);
const [color, setColor] = useState("#FFFFFF");
@@ -17,6 +21,22 @@ export function TextOverlayControls({ onChange }: TextOverlayControlsProps) {
const [backgroundColor, setBackgroundColor] = useState("#000000");
const [shadow, setShadow] = useState(true);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.text != null) setText(String(initialSettings.text));
if (initialSettings.fontSize != null) setFontSize(Number(initialSettings.fontSize));
if (initialSettings.color != null) setColor(String(initialSettings.color));
if (initialSettings.position != null)
setPosition(initialSettings.position as "top" | "center" | "bottom");
if (initialSettings.backgroundBox != null)
setBackgroundBox(Boolean(initialSettings.backgroundBox));
if (initialSettings.backgroundColor != null)
setBackgroundColor(String(initialSettings.backgroundColor));
if (initialSettings.shadow != null) setShadow(Boolean(initialSettings.shadow));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -14,10 +14,11 @@ const OUTPUT_FORMATS = ["png", "jpg", "webp", "avif", "tiff", "gif", "heic", "he
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
export interface UpscaleControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function UpscaleControls({ onChange }: UpscaleControlsProps) {
export function UpscaleControls({ settings: initialSettings, onChange }: UpscaleControlsProps) {
const [scale, setScale] = useState(2);
const [model, setModel] = useState<"auto" | "realesrgan" | "lanczos">("auto");
const [faceEnhance, setFaceEnhance] = useState(false);
@@ -25,6 +26,19 @@ export function UpscaleControls({ onChange }: UpscaleControlsProps) {
const [outputFormat, setOutputFormat] = useState<string>("png");
const [quality, setQuality] = useState(95);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.scale != null) setScale(Number(initialSettings.scale));
if (initialSettings.model != null)
setModel(initialSettings.model as "auto" | "realesrgan" | "lanczos");
if (initialSettings.faceEnhance != null) setFaceEnhance(Boolean(initialSettings.faceEnhance));
if (initialSettings.denoise != null) setDenoise(Number(initialSettings.denoise));
if (initialSettings.format != null) setOutputFormat(String(initialSettings.format));
if (initialSettings.quality != null) setQuality(Number(initialSettings.quality));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
@@ -7,10 +7,14 @@ import { useFileStore } from "@/stores/file-store";
type Position = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "tiled";
export interface WatermarkTextControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
}
export function WatermarkTextControls({ onChange }: WatermarkTextControlsProps) {
export function WatermarkTextControls({
settings: initialSettings,
onChange,
}: WatermarkTextControlsProps) {
const [text, setText] = useState("Sample Watermark");
const [fontSize, setFontSize] = useState(48);
const [color, setColor] = useState("#000000");
@@ -18,6 +22,18 @@ export function WatermarkTextControls({ onChange }: WatermarkTextControlsProps)
const [position, setPosition] = useState<Position>("center");
const [rotation, setRotation] = useState(0);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.text != null) setText(String(initialSettings.text));
if (initialSettings.fontSize != null) setFontSize(Number(initialSettings.fontSize));
if (initialSettings.color != null) setColor(String(initialSettings.color));
if (initialSettings.opacity != null) setOpacity(Number(initialSettings.opacity));
if (initialSettings.position != null) setPosition(initialSettings.position as Position);
if (initialSettings.rotation != null) setRotation(Number(initialSettings.rotation));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;