mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
QA + image-tool depth pass: codec/eraser/PDF fixes, modality renames, 13 image tools deepened (#249)
* fix(media): mux container-correct codecs in video tools Video tools hardcoded H.264 (and AAC) while keeping the input's container extension, so a .webm input produced an invalid file (ffmpeg exit 234: H.264 cannot be muxed into WebM). Add shared videoEncodeArgsForContainer/audioEncodeArgsForContainer helpers (vp9+opus for webm, theora+vorbis for ogv, h264+aac otherwise) and apply them across 14 tools; re-encode audio to AAC in burn-subtitles (forced mp4). Adds a webm regression test for change-fps. * fix(eraser): recover Object Eraser when its progress SSE drops The eraser used a bespoke EventSource with no recovery, so a dropped SSE left the UI stuck at ~25% forever even though the backend job had finished and saved its result. Add a resilient subscription (reconnect on tab refocus, which replays the cached terminal frame; 5-minute stall timeout) mirroring the standard processor's PR #203/#204 recovery. * feat(ui): rename the Documents modality to PDF and Data to Files Updates modality display names, the home-page tabs, the tool-page breadcrumb, and the homePage.documents/data + modalities labels across all 21 locales. URL slugs are unchanged for link stability. * feat(compress-pdf): add quality and target-size compression modes Mirror the image Compress tool: a quality slider (1-100) and a target file size, replacing the screen/ebook/printer preset. Adds gsCompressPdfQuality to doc-engine (quality maps to image downsample DPI, the dominant size lever for PDFs); target-size binary-searches the DPI for the highest quality under the target. The frontend reuses the shared CompressControls component, so no new translation strings are needed. * feat(ocr-pdf): show the PDF preview and extracted text side by side ocr-pdf fell back to the image viewer, which cannot render a PDF, so the right pane showed 'Preview not available' and the extracted text was only a download. It now uses a custom results view (custom-results display mode) rendering the input PDF via pdf.js (DocumentView gains an inputOnly prop, since the tool's output is a .txt) next to the extracted OCR text, with a copy button. * feat(ui): link the modality breadcrumb to its tools tab The modality segment of the tool breadcrumb (PDF, Image, Video, Audio, Files) is now a link to /?modality=<tab>. The home page reads the param, activates the matching tab, and cleans the URL, so it returns to the existing Tools page filtered to that modality without a new page. Handles the file modality whose tab key is 'data'. * feat(circle-crop): add zoom/offset framing, border, background, and output size Upgrade the circle-crop tool from a bare centered crop into a framing and styling tool. New settings (all backward-compatible with the old empty payload): - zoom (1-5x) plus offsetX/offsetY (0-1) to control how tight the circle is and where it sits in the source image - borderWidth (0-200px) plus borderColor for an optional ring - background: transparent (clear corners) or a hex fill - outputSize for a square output; omitted keeps native size The settings panel gains an inline draggable circular preview that mirrors the framing live, a zoom slider, a border slider with color, a transparent/color background toggle, and an output-size field. Adds an integration test covering output size, border, and a solid background. * feat(image-tools): flesh out five thin tools (gif-webp, histogram, favicon, color-palette, lqip) Tier A of the image-tool depth pass. Each of these was as bare as the old circle-crop (empty settings, opaque or invisible output). Now: - gif-webp: quality, lossless, and resize-percent controls; shows before/after size - histogram: returns full per-channel bins + stats; the settings panel renders an inline interactive histogram with R/G/B/Luma toggles, linear/log scale, and a mean/median/stdev readout (server PNG still downloadable) - favicon: background fill, padding, corner-radius, theme color, and a per-size checklist, with a live preview grid; the route applies the styling and honors the size filter - color-palette: count (2-16) and hex/rgb/hsl format controls, median-cut extraction, a palette strip, and CSS/JSON export - lqip-placeholder: blur/pixelate/solid strategies, format and quality; the output panel now surfaces the data URI with copy plus HTML/CSS snippets and a preview (previously the deliverable was never shown) Also expose resultPayload from useToolProcessor so a tool can render the route's extra result fields (histogram bins, lqip data URI) in its own panel. Updates the five integration tests to cover the new settings. * feat(image-tools): deepen five thin tools (duotone, vignette, pixelate, background-replace, blur-background) Tier B of the image-tool depth pass. - duotone: preset palettes, an intensity slider that blends the duotone with the original, and a true live duotone preview (a self-contained grayscale + lighten/darken overlay so the pane filter cannot wash it out) - vignette: radius, softness, roundness, and center-x/y controls driving a rebuilt radial gradient, with a matching live overlay - pixelate: a selection mode that exposes the route's region support via a draggable box over the image plus width/height sliders, so a face or plate can be pixelated in isolation - background-replace: gradient backgrounds, edge feather, and webp output on top of the existing solid color; now shown before/after - blur-background: edge feather and webp output; now shown before/after The live previews for duotone and vignette needed onImageStyle to mount the overlay branch in image-viewer. The duotone intensity blend and both AI tools' edge feather were rewritten to splice the alpha channel through raw buffers; joinChannel did not reliably re-tag the merged channel as alpha and a raw-without-encoder buffer broke the next decode. Updates the five integration tests. * fix(data): rename Files modality to Data + 20 Data-tool bug fixes (#247) * fix(ui): restore the Data modality name (revert Files rename) The 'file' modality reverts to the 'Data' label in modality.ts, the home-page tab, and the tools.data + documentsAndFiles i18n keys across all 21 locales. The separate Documents to PDF rename is kept. The URL slug was already /data, so name and slug realign; the tool breadcrumb follows modality.ts automatically. * fix(create-zip): require at least two files before enabling submit create-zip enabled its submit button with a single file, but the backend rejects fewer than two files ('Zipping needs at least two files'), producing a 422 error. Gate the button on files.length >= 2 to match the sibling merge-csvs tool. Found during the Data-modality QA sweep. * fix(data): resolve 17 bugs found in a deeper Data-tool review Crashes (threw an internal error on otherwise-valid input): - csv-json: a primitive JSON array like [1,2,3] threw "Unable to serialize"; now a clear error. - json-xml: a null or primitive JSON root crashed the XML builder; now a clear 4xx. - yaml-json: an empty or comment-only YAML returned undefined and threw on Buffer.from; now emits null. Data loss / wrong output: - csv-json: nested objects rendered as "[object Object]" (now serialized to JSON); heterogeneous objects dropped columns (now the union of all keys). - xml-to-csv: leaked fast-xml-parser markers ("@_" on attributes, "#text") into CSV headers (now cleaned); a single-record XML failed to tabulate (now a 1-row table); heterogeneous records dropped columns (now the union of all keys). - csv-excel: xlsx date cells were rendered in the server timezone via Date.toString (now ISO 8601, round-trippable). - create-zip and extract-zip: filename/basename collisions overwrote zip entries and silently lost a file; dedup now checks generated names and guarantees uniqueness. - chart-maker: negative values produced invalid/degenerate SVG that Sharp silently dropped; now rejected with a clear message. Empty output / validation: - split-csv: a header-only CSV produced an empty zip; now errors with "No data rows to split". - extract-zip: a directory-only zip produced an empty zip; now errors with "No extractable files found". - create-zip and merge-csvs: a single-file request fell through to the worker and returned 422; the factory now supports minInputs and returns 400 pre-enqueue. UI: - review-panel: the result card showed "Saved +X%" when the output grew; the savings row now appears only when the file is actually smaller (Original/Processed sizes always shown). Found via two adversarial code-review passes over the 10 Data routes. All 24 fix + regression checks pass against a fresh Docker stack on :1359. * fix(data): clean 400 for unsafe-zip entries; drop header on split keepHeader=false - tool-factory: add an opt-in preValidate hook that runs after input prep and before enqueue. Throwing InputValidationError there returns its statusCode (400) instead of the worker's generic 422. BullMQ loses the error class across the job boundary, so InputValidationErrors thrown in the worker cannot be mapped to their status; pre-enqueue validation can. - extract-zip: validate entry paths via preValidate, rejecting path-traversal and absolute-path archives (and unreadable/corrupt zips) with a clear 400. The processV2 guards remain as defense-in-depth for the pipeline/batch path. - split-csv: keepHeader=false now drops the header (parts contain only data rows) instead of keeping it as the first data row of part-1. Verified against a fresh Docker stack: unsafe / absolute / corrupt zips -> 400, normal zip still 200; split keepHeader=false drops the header while true repeats it in each part. No regressions across 51 fix + scenario checks. * feat(image-tools): deepen image-pad and sprite-sheet, fix sprite-sheet multi-file submit Tier C of the image-tool depth pass. - image-pad: a custom W:H ratio alongside the presets, a background mode (solid color, transparent, or an Instagram-style blurred cover fill), and an extra padding margin. The settings panel gains a real live preview of the padded canvas (it previously declared live-preview but rendered nothing) via onImageStyle + onImageOverlay. - sprite-sheet: PNG/WebP/JPEG output with a quality control, and the coordinate map it already computes is now returned and surfaced as Copy CSS (per-frame background-position rules) and Copy JSON exports. Also fix a pre-existing sprite-sheet bug: with more than one image the panel called processAllFiles, fanning out to the per-file batch route (422). It now calls processFiles, which packs all images into a single sheet request (it is a MULTI_FILE tool). Updates both integration tests. * fix(media): preserve source sample rate after loudnorm (#243) ffmpeg's loudnorm filter runs internally at 192 kHz and emits 192 kHz unless the chain resamples back. normalize-audio and video-loudnorm therefore produced 192 kHz output (4.3x larger files) regardless of the input rate. Append aresample to restore the input's sample rate. runMediaTool now exposes the input audio sample rate to its args callback. * fix(color-palette): collapse solid-color images to one swatch The median-cut bucket selector started bestRange at -1, so a uniform bucket (range 0) still satisfied the > comparison and kept splitting, yielding N identical swatches for a solid-color image. Start at 0 so only buckets with real color spread are split. * fix(lint): annotate implicit-any lets in saml and user-files biome noImplicitAnyLet flagged the bare let in saml.ts (profile) and user-files.ts (stream); add derived type annotations (type-only, no behavior change). Latent on main via the turbo lint cache; surfaced when the Data changes busted the apps/api lint cache.
This commit is contained in:
@@ -6,20 +6,39 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type BgType = "color" | "gradient";
|
||||
type OutputFormat = "png" | "webp";
|
||||
|
||||
export function BackgroundReplaceSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("background-replace");
|
||||
|
||||
const [backgroundType, setBackgroundType] = useState<BgType>("color");
|
||||
const [color, setColor] = useState("#ffffff");
|
||||
const [gradientColor1, setGradientColor1] = useState("#ffffff");
|
||||
const [gradientColor2, setGradientColor2] = useState("#000000");
|
||||
const [gradientAngle, setGradientAngle] = useState(180);
|
||||
const [feather, setFeather] = useState(0);
|
||||
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
|
||||
|
||||
const ts = t.toolSettings["background-replace"];
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { color };
|
||||
const settings = {
|
||||
backgroundType,
|
||||
color,
|
||||
...(backgroundType === "gradient" && {
|
||||
gradientColor1,
|
||||
gradientColor2,
|
||||
gradientAngle,
|
||||
}),
|
||||
feather,
|
||||
format: outputFormat,
|
||||
};
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -34,29 +53,168 @@ export function BackgroundReplaceSettings() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Color */}
|
||||
{/* Background Type Toggle */}
|
||||
<div>
|
||||
<label htmlFor="bg-replace-color" className="mb-1.5 block text-sm font-medium">
|
||||
{ts.color}
|
||||
<span className="mb-1.5 block text-sm font-medium">Background Type</span>
|
||||
<div className="flex gap-1 rounded-md border border-border p-1">
|
||||
{(["color", "gradient"] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setBackgroundType(type)}
|
||||
className={`flex-1 rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
backgroundType === type
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{type === "color" ? "Color" : "Gradient"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solid Color Picker */}
|
||||
{backgroundType === "color" && (
|
||||
<div>
|
||||
<label htmlFor="bg-replace-color" className="mb-1.5 block text-sm font-medium">
|
||||
{ts.color}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="bg-replace-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-10 w-14 cursor-pointer rounded-md border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setColor(v);
|
||||
}}
|
||||
className="border-border bg-background w-28 rounded-md border px-3 py-2 font-mono text-sm"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient Controls */}
|
||||
{backgroundType === "gradient" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="bg-replace-grad1"
|
||||
className="mb-1.5 block text-xs text-muted-foreground"
|
||||
>
|
||||
Color 1
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="bg-replace-grad1"
|
||||
type="color"
|
||||
value={gradientColor1}
|
||||
onChange={(e) => setGradientColor1(e.target.value)}
|
||||
className="h-9 w-12 cursor-pointer rounded-md border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={gradientColor1}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setGradientColor1(v);
|
||||
}}
|
||||
className="border-border bg-background w-full rounded-md border px-2 py-1.5 font-mono text-xs"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="bg-replace-grad2"
|
||||
className="mb-1.5 block text-xs text-muted-foreground"
|
||||
>
|
||||
Color 2
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="bg-replace-grad2"
|
||||
type="color"
|
||||
value={gradientColor2}
|
||||
onChange={(e) => setGradientColor2(e.target.value)}
|
||||
className="h-9 w-12 cursor-pointer rounded-md border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={gradientColor2}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setGradientColor2(v);
|
||||
}}
|
||||
className="border-border bg-background w-full rounded-md border px-2 py-1.5 font-mono text-xs"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="bg-replace-angle"
|
||||
className="mb-1.5 block text-xs text-muted-foreground"
|
||||
>
|
||||
Angle: {gradientAngle}°
|
||||
</label>
|
||||
<input
|
||||
id="bg-replace-angle"
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={gradientAngle}
|
||||
onChange={(e) => setGradientAngle(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Edge Feather */}
|
||||
<div>
|
||||
<label htmlFor="bg-replace-feather" className="mb-1.5 block text-sm font-medium">
|
||||
Edge Feather: {feather}px
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="bg-replace-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="h-10 w-14 cursor-pointer rounded-md border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (/^#[0-9a-fA-F]{0,6}$/.test(v)) setColor(v);
|
||||
}}
|
||||
className="border-border bg-background w-28 rounded-md border px-3 py-2 font-mono text-sm"
|
||||
maxLength={7}
|
||||
/>
|
||||
<input
|
||||
id="bg-replace-feather"
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={feather}
|
||||
onChange={(e) => setFeather(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Output Format Toggle */}
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium">Output Format</span>
|
||||
<div className="flex gap-1 rounded-md border border-border p-1">
|
||||
{(["png", "webp"] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
onClick={() => setOutputFormat(fmt)}
|
||||
className={`flex-1 rounded px-3 py-1.5 text-xs font-medium uppercase transition-colors ${
|
||||
outputFormat === fmt
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{fmt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type OutputFormat = "png" | "webp";
|
||||
|
||||
export function BlurBackgroundSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
@@ -13,13 +15,15 @@ export function BlurBackgroundSettings() {
|
||||
useToolProcessor("blur-background");
|
||||
|
||||
const [intensity, setIntensity] = useState(50);
|
||||
const [feather, setFeather] = useState(0);
|
||||
const [outputFormat, setOutputFormat] = useState<OutputFormat>("png");
|
||||
|
||||
const ts = t.toolSettings["blur-background"];
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { intensity };
|
||||
const settings = { intensity, feather, format: outputFormat };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -41,6 +45,7 @@ export function BlurBackgroundSettings() {
|
||||
</label>
|
||||
<input
|
||||
id="blur-bg-intensity"
|
||||
data-testid="blur-bg-intensity"
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
@@ -50,6 +55,53 @@ export function BlurBackgroundSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Edge Feather */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<label htmlFor="blur-bg-feather" className="text-sm font-medium">
|
||||
Edge Feather
|
||||
</label>
|
||||
<span className="text-xs font-mono text-muted-foreground tabular-nums">
|
||||
{feather === 0 ? "Off" : `${feather}px`}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="blur-bg-feather"
|
||||
data-testid="blur-bg-feather"
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={feather}
|
||||
onChange={(e) => setFeather(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Softens the edge between subject and blurred background
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Output Format */}
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-medium">Output Format</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(["png", "webp"] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
data-testid={`blur-bg-format-${fmt}`}
|
||||
onClick={() => setOutputFormat(fmt)}
|
||||
className={`rounded-lg border py-2 px-2 text-xs font-medium uppercase transition-colors ${
|
||||
outputFormat === fmt
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{fmt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress / Submit */}
|
||||
{processing && progress ? (
|
||||
<ProgressCard
|
||||
@@ -63,6 +115,7 @@ export function BlurBackgroundSettings() {
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="blur-bg-submit"
|
||||
disabled={!hasFile || processing}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted w-full rounded-md px-4 py-2 text-sm font-medium disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -82,6 +135,7 @@ export function BlurBackgroundSettings() {
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="blur-bg-download"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
|
||||
@@ -1,36 +1,242 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const PREVIEW_D = 200; // circle diameter in the inline preview, px
|
||||
|
||||
export function CircleCropSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const blobUrl = entry?.blobUrl;
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("circle-crop");
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, {});
|
||||
} else {
|
||||
processFiles(files, {});
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [offsetX, setOffsetX] = useState(0.5);
|
||||
const [offsetY, setOffsetY] = useState(0.5);
|
||||
const [borderWidth, setBorderWidth] = useState(0);
|
||||
const [borderColor, setBorderColor] = useState("#ffffff");
|
||||
const [bgMode, setBgMode] = useState<"transparent" | "color">("transparent");
|
||||
const [bgColor, setBgColor] = useState("#ffffff");
|
||||
const [outputSize, setOutputSize] = useState("");
|
||||
|
||||
// Natural image dimensions, for accurate framing math.
|
||||
useEffect(() => {
|
||||
if (!blobUrl) {
|
||||
setDims(null);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => setDims({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.src = blobUrl;
|
||||
}, [blobUrl]);
|
||||
|
||||
// Preview geometry (mirrors the backend region math).
|
||||
const W = dims?.w ?? 1;
|
||||
const H = dims?.h ?? 1;
|
||||
const d = Math.max(1, Math.min(W, H) / zoom);
|
||||
const scale = PREVIEW_D / d;
|
||||
const left = (W - d) * offsetX;
|
||||
const top = (H - d) * offsetY;
|
||||
const bwPx = borderWidth * scale;
|
||||
|
||||
// Drag-to-pan the circle.
|
||||
const drag = useRef<{ px: number; py: number; ox: number; oy: number } | null>(null);
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (!dims) return;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
drag.current = { px: e.clientX, py: e.clientY, ox: offsetX, oy: offsetY };
|
||||
};
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!drag.current || !dims) return;
|
||||
const dx = e.clientX - drag.current.px;
|
||||
const dy = e.clientY - drag.current.py;
|
||||
const rangeX = W - d;
|
||||
const rangeY = H - d;
|
||||
if (rangeX > 0) {
|
||||
const nl = Math.min(Math.max(drag.current.ox * rangeX - dx / scale, 0), rangeX);
|
||||
setOffsetX(nl / rangeX);
|
||||
}
|
||||
if (rangeY > 0) {
|
||||
const nt = Math.min(Math.max(drag.current.oy * rangeY - dy / scale, 0), rangeY);
|
||||
setOffsetY(nt / rangeY);
|
||||
}
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
drag.current = null;
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = {
|
||||
zoom,
|
||||
offsetX,
|
||||
offsetY,
|
||||
borderWidth,
|
||||
borderColor,
|
||||
background: bgMode === "transparent" ? "transparent" : bgColor,
|
||||
};
|
||||
const sz = Number.parseInt(outputSize, 10);
|
||||
if (!Number.isNaN(sz) && sz >= 16) settings.outputSize = sz;
|
||||
if (files.length > 1) processAllFiles(files, settings);
|
||||
else processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess = hasFile && !processing;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Crops the image to a centered circle with transparent corners. Output is always PNG.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{/* Live framing preview */}
|
||||
{hasFile && blobUrl && (
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className="relative rounded-full"
|
||||
style={{
|
||||
width: PREVIEW_D + bwPx * 2,
|
||||
height: PREVIEW_D + bwPx * 2,
|
||||
padding: bwPx,
|
||||
background: bwPx > 0 ? borderColor : "transparent",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-full touch-none cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
width: PREVIEW_D,
|
||||
height: PREVIEW_D,
|
||||
background: bgMode === "color" ? bgColor : "transparent",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
>
|
||||
{/* checkerboard hint for transparency */}
|
||||
{bgMode === "transparent" && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg,#0001 25%,transparent 25%),linear-gradient(-45deg,#0001 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#0001 75%),linear-gradient(-45deg,transparent 75%,#0001 75%)",
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0,0 8px,8px -8px,-8px 0",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute max-w-none select-none"
|
||||
style={{
|
||||
width: W * scale,
|
||||
height: H * scale,
|
||||
left: -left * scale,
|
||||
top: -top * scale,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasFile && (
|
||||
<p className="text-center text-[10px] text-muted-foreground">
|
||||
Drag the preview to reposition
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Zoom */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Zoom</span>
|
||||
<span className="text-xs font-mono text-foreground">{zoom.toFixed(1)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Border */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Border</span>
|
||||
<span className="text-xs font-mono text-foreground">{borderWidth}px</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={borderWidth}
|
||||
onChange={(e) => setBorderWidth(Number(e.target.value))}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={borderColor}
|
||||
onChange={(e) => setBorderColor(e.target.value)}
|
||||
aria-label="Border color"
|
||||
className="h-7 w-9 shrink-0 rounded border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Background</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex flex-1 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgMode("transparent")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${bgMode === "transparent" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Transparent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgMode("color")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${bgMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Color
|
||||
</button>
|
||||
</div>
|
||||
{bgMode === "color" && (
|
||||
<input
|
||||
type="color"
|
||||
value={bgColor}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
aria-label="Background color"
|
||||
className="h-7 w-9 shrink-0 rounded border border-border bg-background"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output size */}
|
||||
<div>
|
||||
<label htmlFor="cc-output-size" className="text-xs text-muted-foreground">
|
||||
Output size (px)
|
||||
</label>
|
||||
<input
|
||||
id="cc-output-size"
|
||||
type="number"
|
||||
min={16}
|
||||
value={outputSize}
|
||||
onChange={(e) => setOutputSize(e.target.value)}
|
||||
placeholder="Original"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -45,10 +251,11 @@ export function CircleCropSettings() {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
data-testid="circle-crop-submit"
|
||||
disabled={!canProcess}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{files.length > 1
|
||||
? t.toolSettings["circle-crop"].submitBatch.replace("{count}", String(files.length))
|
||||
@@ -67,6 +274,6 @@ export function CircleCropSettings() {
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { Check, ClipboardCopy, Copy, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type ColorFormat = "hex" | "rgb" | "hsl";
|
||||
|
||||
export function ColorPaletteSettings() {
|
||||
const { t } = useTranslation();
|
||||
const ts = t.toolSettings["color-palette"];
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [count, setCount] = useState(8);
|
||||
const [format, setFormat] = useState<ColorFormat>("hex");
|
||||
const [colors, setColors] = useState<string[]>([]);
|
||||
const [hexColors, setHexColors] = useState<string[]>([]);
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null);
|
||||
const [copiedExport, setCopiedExport] = useState<"css" | "json" | null>(null);
|
||||
|
||||
const handleProcess = async () => {
|
||||
if (files.length === 0) return;
|
||||
@@ -17,10 +24,12 @@ export function ColorPaletteSettings() {
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setColors([]);
|
||||
setHexColors([]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append("settings", JSON.stringify({ count, format }));
|
||||
|
||||
const res = await fetch("/api/v1/tools/color-palette", {
|
||||
method: "POST",
|
||||
@@ -35,6 +44,7 @@ export function ColorPaletteSettings() {
|
||||
|
||||
const data = await res.json();
|
||||
setColors(data.colors);
|
||||
setHexColors(data.hex);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : ts.extracting);
|
||||
} finally {
|
||||
@@ -50,10 +60,65 @@ export function ColorPaletteSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const copyCss = async () => {
|
||||
const vars = hexColors.map((c, i) => ` --color-${i + 1}: ${c};`).join("\n");
|
||||
const css = `:root {\n${vars}\n}`;
|
||||
const ok = await copyToClipboard(css);
|
||||
if (ok) {
|
||||
setCopiedExport("css");
|
||||
setTimeout(() => setCopiedExport(null), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const copyJson = async () => {
|
||||
const ok = await copyToClipboard(JSON.stringify(colors));
|
||||
if (ok) {
|
||||
setCopiedExport("json");
|
||||
setTimeout(() => setCopiedExport(null), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Color count slider */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Colors</span>
|
||||
<span className="text-xs font-mono text-foreground">{count}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={16}
|
||||
step={1}
|
||||
value={count}
|
||||
onChange={(e) => setCount(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
data-testid="color-palette-count"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Format toggle */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Format</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{(["hex", "rgb", "hsl"] as const).map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f}
|
||||
onClick={() => setFormat(f)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${format === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
data-testid={`color-palette-format-${f}`}
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extract button */}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="color-palette-submit"
|
||||
@@ -68,23 +133,68 @@ export function ColorPaletteSettings() {
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{ts.dominantColors} ({colors.length})
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{/* Full-width palette strip */}
|
||||
<div
|
||||
className="flex h-10 rounded overflow-hidden border border-border"
|
||||
data-testid="color-palette-strip"
|
||||
>
|
||||
{hexColors.map((hex) => (
|
||||
<div key={hex} className="flex-1" style={{ backgroundColor: hex }} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header + export buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{ts.dominantColors} ({colors.length})
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCss}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-muted-foreground hover:bg-muted transition-colors"
|
||||
data-testid="color-palette-copy-css"
|
||||
>
|
||||
{copiedExport === "css" ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-3 w-3" />
|
||||
)}
|
||||
CSS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyJson}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-muted-foreground hover:bg-muted transition-colors"
|
||||
data-testid="color-palette-copy-json"
|
||||
>
|
||||
{copiedExport === "json" ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-3 w-3" />
|
||||
)}
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Swatch list */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{colors.map((color, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={color}
|
||||
key={hexColors[i]}
|
||||
onClick={() => copyColor(color, i)}
|
||||
className="flex items-center gap-2 p-1.5 rounded border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-border shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
style={{ backgroundColor: hexColors[i] }}
|
||||
/>
|
||||
<span className="text-xs font-mono text-foreground flex-1 text-start">{color}</span>
|
||||
<span className="text-xs font-mono text-foreground flex-1 text-start truncate">
|
||||
{color}
|
||||
</span>
|
||||
{copiedIdx === i ? (
|
||||
<Check className="h-3 w-3 text-green-500 shrink-0" />
|
||||
) : (
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Preset = "screen" | "ebook" | "printer";
|
||||
import { CompressControls } from "./compress-settings";
|
||||
|
||||
export function CompressPdfSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,14 +12,16 @@ export function CompressPdfSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, progress } =
|
||||
useToolProcessor("compress-pdf");
|
||||
|
||||
const [preset, setPreset] = useState<Preset>("ebook");
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasMultiple = files.length > 1;
|
||||
// Quality mode is always runnable; target-size needs a positive target.
|
||||
const canProcess =
|
||||
settings.mode === "quality" ||
|
||||
(settings.mode === "targetSize" && Number(settings.targetSizeKb) > 0);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { preset };
|
||||
if (hasMultiple) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -30,21 +31,8 @@ export function CompressPdfSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cpdf-preset" className="text-xs text-muted-foreground">
|
||||
{s.preset}
|
||||
</label>
|
||||
<select
|
||||
id="cpdf-preset"
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as Preset)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="screen">{s.screen}</option>
|
||||
<option value="ebook">{s.ebook}</option>
|
||||
<option value="printer">{s.printer}</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Same quality / target-size controls as the image compress tool */}
|
||||
<CompressControls onChange={setSettings} />
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -62,7 +50,7 @@ export function CompressPdfSettings() {
|
||||
type="button"
|
||||
data-testid="compress-pdf-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
disabled={!hasFile || !canProcess || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{hasMultiple ? format(s.submitBatch, { count: files.length }) : s.submit}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function CreateZipSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, progress } = useToolProcessor("create-zip");
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasEnough = files.length >= 2;
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {});
|
||||
@@ -33,7 +33,7 @@ export function CreateZipSettings() {
|
||||
type="button"
|
||||
data-testid="create-zip-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
disabled={!hasEnough || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{s.submit}
|
||||
|
||||
@@ -10,7 +10,7 @@ pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
).href;
|
||||
|
||||
/** pdf.js canvas viewer for the document display mode (spec 4.6). */
|
||||
export function DocumentView() {
|
||||
export function DocumentView({ inputOnly = false }: { inputOnly?: boolean } = {}) {
|
||||
const { t } = useTranslation();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -18,8 +18,10 @@ export function DocumentView() {
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const hasProcessedUrl = !!entry?.processedUrl;
|
||||
const src = entry?.processedUrl ?? entry?.blobUrl;
|
||||
// inputOnly keeps the original input on screen for tools whose output is not
|
||||
// a previewable PDF (e.g. ocr-pdf, whose output is a .txt transcript).
|
||||
const hasProcessedUrl = !inputOnly && !!entry?.processedUrl;
|
||||
const src = inputOnly ? entry?.blobUrl : (entry?.processedUrl ?? entry?.blobUrl);
|
||||
|
||||
// F6: detect non-PDF input files (word, excel, html, markdown, etc.)
|
||||
const isPdfInput = entry?.file?.name?.toLowerCase().endsWith(".pdf") ?? false;
|
||||
|
||||
@@ -1,21 +1,80 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function DuotoneSettings() {
|
||||
const PRESETS: { label: string; shadow: string; highlight: string }[] = [
|
||||
{ label: "Classic", shadow: "#1e3a8a", highlight: "#fbbf24" },
|
||||
{ label: "Noir", shadow: "#000000", highlight: "#ffffff" },
|
||||
{ label: "Sepia", shadow: "#2d1b00", highlight: "#ffe8c2" },
|
||||
{ label: "Midnight", shadow: "#0f2027", highlight: "#78ffd6" },
|
||||
{ label: "Sunset", shadow: "#3a1c71", highlight: "#ffaf7b" },
|
||||
{ label: "Cyber", shadow: "#0f0c29", highlight: "#f857a6" },
|
||||
{ label: "Ocean", shadow: "#000428", highlight: "#43cea2" },
|
||||
{ label: "Forest", shadow: "#022c12", highlight: "#9be15d" },
|
||||
];
|
||||
|
||||
interface DuotoneSettingsProps {
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (node: React.ReactNode) => void;
|
||||
}
|
||||
|
||||
export function DuotoneSettings({ onImageStyle, onImageOverlay }: DuotoneSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const blobUrl = entry?.blobUrl;
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("duotone");
|
||||
|
||||
const [shadow, setShadow] = useState("#1e3a8a");
|
||||
const [highlight, setHighlight] = useState("#fbbf24");
|
||||
const [intensity, setIntensity] = useState(100);
|
||||
|
||||
// Stable refs for preview callbacks to avoid stale closures
|
||||
const onImageStyleRef = useRef(onImageStyle);
|
||||
useEffect(() => {
|
||||
onImageStyleRef.current = onImageStyle;
|
||||
});
|
||||
const onImageOverlayRef = useRef(onImageOverlay);
|
||||
useEffect(() => {
|
||||
onImageOverlayRef.current = onImageOverlay;
|
||||
});
|
||||
|
||||
// Live preview: a self-contained duotone overlay (its own grayscale copy of
|
||||
// the image plus lighten/darken color layers, isolated so the parent pane
|
||||
// filter can't re-gray it). Intensity fades toward the real color image
|
||||
// beneath, matching the backend blend. onImageStyle activates the wrapper
|
||||
// branch in image-viewer so the overlay children actually mount.
|
||||
useEffect(() => {
|
||||
if (!blobUrl) return;
|
||||
onImageStyleRef.current?.({});
|
||||
onImageOverlayRef.current?.(
|
||||
<div className="absolute inset-0" style={{ opacity: intensity / 100, isolation: "isolate" }}>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 h-full w-full select-none"
|
||||
style={{ objectFit: "contain", filter: "grayscale(1)" }}
|
||||
/>
|
||||
<div className="absolute inset-0" style={{ background: shadow, mixBlendMode: "lighten" }} />
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: highlight, mixBlendMode: "darken" }}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
onImageStyleRef.current?.(null);
|
||||
onImageOverlayRef.current?.(null);
|
||||
};
|
||||
}, [shadow, highlight, intensity, blobUrl]);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { shadow, highlight };
|
||||
const settings = { shadow, highlight, intensity };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -33,6 +92,36 @@ export function DuotoneSettings() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Presets */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Presets</p>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShadow(p.shadow);
|
||||
setHighlight(p.highlight);
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1 py-1.5 px-1 rounded transition-colors ${
|
||||
shadow === p.shadow && highlight === p.highlight
|
||||
? "bg-primary/10 ring-1 ring-primary"
|
||||
: "bg-muted hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
<div className="w-full h-5 rounded-sm border border-border/50 flex overflow-hidden">
|
||||
<div className="flex-1" style={{ background: p.shadow }} />
|
||||
<div className="flex-1" style={{ background: p.highlight }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight truncate w-full text-center">
|
||||
{p.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shadow Color */}
|
||||
<div>
|
||||
<label htmlFor="duotone-shadow" className="text-xs text-muted-foreground">
|
||||
@@ -77,6 +166,25 @@ export function DuotoneSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intensity Slider */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="duotone-intensity" className="text-xs text-muted-foreground">
|
||||
Intensity
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums">{intensity}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="duotone-intensity"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={intensity}
|
||||
onChange={(e) => setIntensity(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download, Redo, Trash2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
@@ -21,6 +21,97 @@ const OUTPUT_FORMATS = [
|
||||
] as const;
|
||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl"];
|
||||
|
||||
const SSE_STALL_TIMEOUT_MS = 5 * 60_000;
|
||||
|
||||
interface ProgressHandlers {
|
||||
onProgress?: (percent: number) => void;
|
||||
onComplete: (result: Record<string, unknown>) => void;
|
||||
onFailed: (error: string) => void;
|
||||
onStall: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to async (202) job progress with the same resilience as the
|
||||
* standard tool processor (PRs #203/#204). The original eraser opened a bare
|
||||
* EventSource with no recovery: if SSE silently died (mobile backgrounding,
|
||||
* flaky network, proxy buffering) the UI hung forever at the last percent
|
||||
* (~25%) even though the backend job had finished and saved its result.
|
||||
*
|
||||
* This reconnects on tab refocus -- the progress endpoint replays the
|
||||
* terminal frame from its 10-minute Redis cache, so a job that completed
|
||||
* while SSE was dead still resolves -- and arms a stall timeout that fails
|
||||
* gracefully instead of hanging. Returns a cleanup the caller must invoke on
|
||||
* sync completion, error, or unmount.
|
||||
*/
|
||||
function subscribeJobProgress(clientJobId: string, handlers: ProgressHandlers): () => void {
|
||||
let es: EventSource | null = null;
|
||||
let stall: ReturnType<typeof setTimeout> | null = null;
|
||||
let done = false;
|
||||
|
||||
const onVisible = () => {
|
||||
if (done || document.visibilityState !== "visible") return;
|
||||
if (es && es.readyState === EventSource.OPEN) return;
|
||||
setTimeout(open, 500);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (stall) clearTimeout(stall);
|
||||
stall = null;
|
||||
if (es) es.close();
|
||||
es = null;
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
|
||||
const resetStall = () => {
|
||||
if (stall) clearTimeout(stall);
|
||||
stall = setTimeout(() => {
|
||||
cleanup();
|
||||
handlers.onStall();
|
||||
}, SSE_STALL_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
function open() {
|
||||
if (done) return;
|
||||
if (es && es.readyState === EventSource.OPEN) return;
|
||||
if (es) es.close();
|
||||
try {
|
||||
es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type !== "single") return;
|
||||
resetStall();
|
||||
if (data.phase === "complete" && data.result) {
|
||||
cleanup();
|
||||
handlers.onComplete(data.result as Record<string, unknown>);
|
||||
return;
|
||||
}
|
||||
if (data.phase === "failed") {
|
||||
cleanup();
|
||||
handlers.onFailed(typeof data.error === "string" ? data.error : "Processing failed");
|
||||
return;
|
||||
}
|
||||
if (typeof data.percent === "number") handlers.onProgress?.(data.percent);
|
||||
} catch {
|
||||
// Ignore malformed SSE frames
|
||||
}
|
||||
};
|
||||
// A transient drop triggers the browser's built-in reconnect; on reconnect
|
||||
// the backend replays the terminal frame, so a completed job still resolves.
|
||||
es.onerror = () => {};
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
open();
|
||||
resetStall();
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
interface EraseObjectSettingsProps {
|
||||
eraserRef: React.RefObject<EraserCanvasRef | null>;
|
||||
hasStrokes: boolean;
|
||||
@@ -39,21 +130,22 @@ export function EraseObjectSettings({
|
||||
maskedFileCount,
|
||||
}: EraseObjectSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
files,
|
||||
entries,
|
||||
selectedIndex,
|
||||
processing,
|
||||
error,
|
||||
setProcessing,
|
||||
setError,
|
||||
currentEntry,
|
||||
} = useFileStore();
|
||||
const { files, entries, processing, error, setProcessing, setError, currentEntry } =
|
||||
useFileStore();
|
||||
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [progressStage, setProgressStage] = useState<string | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const progressCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Tear down any live progress subscription if the component unmounts mid-job.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
progressCleanupRef.current?.();
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [outputFormat, setOutputFormat] = useState("png");
|
||||
const [quality, setQuality] = useState(95);
|
||||
@@ -66,41 +158,29 @@ export function EraseObjectSettings({
|
||||
): Promise<void> => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const clientJobId = generateId();
|
||||
let asyncMode = false;
|
||||
|
||||
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type !== "single") return;
|
||||
if (data.phase === "complete" && data.result) {
|
||||
es.close();
|
||||
const r = data.result;
|
||||
useFileStore.getState().updateEntry(entryIndex, {
|
||||
processedUrl: r.downloadUrl,
|
||||
processedPreviewUrl: r.previewUrl ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
originalSize: r.originalSize,
|
||||
processedSize: r.processedSize,
|
||||
});
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (data.phase === "failed" && asyncMode) {
|
||||
es.close();
|
||||
reject(new Error(data.error || "Processing failed"));
|
||||
return;
|
||||
}
|
||||
if (typeof data.percent === "number") {
|
||||
onProgress(data.percent);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (!asyncMode) es.close();
|
||||
const applyResult = (r: Record<string, unknown>) => {
|
||||
useFileStore.getState().updateEntry(entryIndex, {
|
||||
processedUrl: r.downloadUrl as string,
|
||||
processedPreviewUrl: (r.previewUrl as string) ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
originalSize: r.originalSize as number,
|
||||
processedSize: r.processedSize as number,
|
||||
});
|
||||
};
|
||||
|
||||
const stopProgress = subscribeJobProgress(clientJobId, {
|
||||
onProgress,
|
||||
onComplete: (r) => {
|
||||
applyResult(r);
|
||||
resolve();
|
||||
},
|
||||
onFailed: (err) => reject(new Error(err)),
|
||||
onStall: () =>
|
||||
reject(new Error("Processing timed out. The result may have saved -- check your files.")),
|
||||
});
|
||||
|
||||
const maskFile = new File([maskBlob], "mask.png", { type: "image/png" });
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
@@ -112,22 +192,11 @@ export function EraseObjectSettings({
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 600_000;
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 202) {
|
||||
asyncMode = true;
|
||||
return;
|
||||
}
|
||||
es.close();
|
||||
if (xhr.status === 202) return;
|
||||
stopProgress();
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
useFileStore.getState().updateEntry(entryIndex, {
|
||||
processedUrl: data.downloadUrl,
|
||||
processedPreviewUrl: data.previewUrl ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
originalSize: data.originalSize,
|
||||
processedSize: data.processedSize,
|
||||
});
|
||||
applyResult(JSON.parse(xhr.responseText));
|
||||
resolve();
|
||||
} catch {
|
||||
reject(new Error("Invalid response"));
|
||||
@@ -150,11 +219,11 @@ export function EraseObjectSettings({
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
es.close();
|
||||
stopProgress();
|
||||
reject(new Error("Network error"));
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
es.close();
|
||||
stopProgress();
|
||||
reject(new Error("Request timed out"));
|
||||
};
|
||||
xhr.open("POST", "/api/v1/tools/erase-object");
|
||||
@@ -191,51 +260,50 @@ export function EraseObjectSettings({
|
||||
}, 1000);
|
||||
|
||||
const clientJobId = generateId();
|
||||
let asyncMode = false;
|
||||
|
||||
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type !== "single") return;
|
||||
|
||||
if (data.phase === "complete" && data.result) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
es.close();
|
||||
const r = data.result;
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: r.downloadUrl,
|
||||
processedPreviewUrl: r.previewUrl ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
originalSize: r.originalSize,
|
||||
processedSize: r.processedSize,
|
||||
});
|
||||
setProcessing(false);
|
||||
setProgressPhase("idle");
|
||||
setProgressStage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.phase === "failed" && asyncMode) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
es.close();
|
||||
setError(data.error || "Processing failed");
|
||||
setProcessing(false);
|
||||
setProgressPhase("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data.percent === "number") {
|
||||
setProgressPhase("processing");
|
||||
setProgressPercent(15 + (data.percent / 100) * 85);
|
||||
}
|
||||
} catch {}
|
||||
const applyResult = (r: Record<string, unknown>) => {
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: r.downloadUrl as string,
|
||||
processedPreviewUrl: (r.previewUrl as string) ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
originalSize: r.originalSize as number,
|
||||
processedSize: r.processedSize as number,
|
||||
});
|
||||
};
|
||||
es.onerror = () => {
|
||||
if (!asyncMode) es.close();
|
||||
|
||||
const finishUi = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
setProcessing(false);
|
||||
setProgressPhase("idle");
|
||||
setProgressStage(null);
|
||||
};
|
||||
|
||||
const stopProgress = subscribeJobProgress(clientJobId, {
|
||||
onProgress: (percent) => {
|
||||
setProgressPhase("processing");
|
||||
setProgressPercent(15 + (percent / 100) * 85);
|
||||
},
|
||||
onComplete: (r) => {
|
||||
progressCleanupRef.current = null;
|
||||
applyResult(r);
|
||||
finishUi();
|
||||
},
|
||||
onFailed: (err) => {
|
||||
progressCleanupRef.current = null;
|
||||
setError(err);
|
||||
finishUi();
|
||||
},
|
||||
onStall: () => {
|
||||
progressCleanupRef.current = null;
|
||||
setError(
|
||||
"Processing timed out with no progress. The result may have saved to your files -- otherwise, try again.",
|
||||
);
|
||||
finishUi();
|
||||
},
|
||||
});
|
||||
progressCleanupRef.current = stopProgress;
|
||||
|
||||
const maskFile = new File([maskBlob], "mask.png", { type: "image/png" });
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -257,25 +325,15 @@ export function EraseObjectSettings({
|
||||
setProgressPercent(15);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 202) {
|
||||
asyncMode = true;
|
||||
return;
|
||||
}
|
||||
// 202 = async: subscribeJobProgress drives completion via SSE.
|
||||
if (xhr.status === 202) return;
|
||||
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
es.close();
|
||||
stopProgress();
|
||||
progressCleanupRef.current = null;
|
||||
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: data.downloadUrl,
|
||||
processedPreviewUrl: data.previewUrl ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
originalSize: data.originalSize,
|
||||
processedSize: data.processedSize,
|
||||
});
|
||||
applyResult(JSON.parse(xhr.responseText));
|
||||
} catch {
|
||||
setError("Invalid response");
|
||||
}
|
||||
@@ -293,23 +351,19 @@ export function EraseObjectSettings({
|
||||
setError(`Processing failed: ${xhr.status}`);
|
||||
}
|
||||
}
|
||||
setProcessing(false);
|
||||
setProgressPhase("idle");
|
||||
setProgressStage(null);
|
||||
finishUi();
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
es.close();
|
||||
stopProgress();
|
||||
progressCleanupRef.current = null;
|
||||
setError("Network error");
|
||||
setProcessing(false);
|
||||
setProgressPhase("idle");
|
||||
finishUi();
|
||||
};
|
||||
xhr.ontimeout = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
es.close();
|
||||
stopProgress();
|
||||
progressCleanupRef.current = null;
|
||||
setError("Request timed out - the server may be overloaded. Try again.");
|
||||
setProcessing(false);
|
||||
setProgressPhase("idle");
|
||||
finishUi();
|
||||
};
|
||||
xhr.open("POST", "/api/v1/tools/erase-object");
|
||||
formatHeaders().forEach((value, key) => {
|
||||
|
||||
@@ -7,19 +7,33 @@ import { formatHeaders } from "@/lib/api";
|
||||
import { format } from "@/lib/format";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const SIZES = [
|
||||
{ name: "favicon-16x16.png", size: "16x16" },
|
||||
{ name: "favicon-32x32.png", size: "32x32" },
|
||||
{ name: "favicon-48x48.png", size: "48x48" },
|
||||
{ name: "apple-touch-icon.png", size: "180x180" },
|
||||
{ name: "android-chrome-192x192.png", size: "192x192" },
|
||||
{ name: "android-chrome-512x512.png", size: "512x512" },
|
||||
{ name: "favicon.ico", size: "32x32" },
|
||||
const SIZE_OPTIONS = [
|
||||
{ size: 16, name: "favicon-16x16.png", label: "16x16" },
|
||||
{ size: 32, name: "favicon-32x32.png", label: "32x32" },
|
||||
{ size: 48, name: "favicon-48x48.png", label: "48x48" },
|
||||
{ size: 180, name: "apple-touch-icon.png", label: "180x180" },
|
||||
{ size: 192, name: "android-chrome-192x192.png", label: "192x192" },
|
||||
{ size: 512, name: "android-chrome-512x512.png", label: "512x512" },
|
||||
];
|
||||
|
||||
const ALL_SIZES = SIZE_OPTIONS.map((s) => s.size);
|
||||
const PREVIEW_BOXES = [64, 48, 32];
|
||||
|
||||
export function FaviconSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files, error, setProcessing, setError } = useFileStore();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const blobUrl = entry?.blobUrl;
|
||||
|
||||
// Settings state
|
||||
const [bgMode, setBgMode] = useState<"transparent" | "color">("transparent");
|
||||
const [bgColor, setBgColor] = useState("#ffffff");
|
||||
const [padding, setPadding] = useState(0);
|
||||
const [radius, setRadius] = useState(0);
|
||||
const [themeColor, setThemeColor] = useState("#ffffff");
|
||||
const [selectedSizes, setSelectedSizes] = useState<Set<number>>(() => new Set(ALL_SIZES));
|
||||
|
||||
// Download / progress state
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [progress, setProgress] = useState({
|
||||
@@ -48,6 +62,15 @@ export function FaviconSettings() {
|
||||
setProcessing(false);
|
||||
};
|
||||
|
||||
const toggleSize = (size: number) => {
|
||||
setSelectedSizes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(size)) next.delete(size);
|
||||
else next.add(size);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: cleanup uses only stable refs and state setters
|
||||
const handleProcess = useCallback(() => {
|
||||
if (files.length === 0) return;
|
||||
@@ -73,6 +96,20 @@ export function FaviconSettings() {
|
||||
formData.append("file", file);
|
||||
}
|
||||
|
||||
// Build settings
|
||||
const settings: Record<string, unknown> = {
|
||||
padding,
|
||||
radius,
|
||||
themeColor,
|
||||
};
|
||||
if (bgMode === "color") {
|
||||
settings.background = bgColor;
|
||||
}
|
||||
if (selectedSizes.size < ALL_SIZES.length) {
|
||||
settings.sizes = Array.from(selectedSizes);
|
||||
}
|
||||
formData.append("settings", JSON.stringify(settings));
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhrRef.current = xhr;
|
||||
xhr.responseType = "blob";
|
||||
@@ -124,7 +161,18 @@ export function FaviconSettings() {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(formData);
|
||||
}, [files, setProcessing, setError, downloadUrl]);
|
||||
}, [
|
||||
files,
|
||||
setProcessing,
|
||||
setError,
|
||||
downloadUrl,
|
||||
bgMode,
|
||||
bgColor,
|
||||
padding,
|
||||
radius,
|
||||
themeColor,
|
||||
selectedSizes,
|
||||
]);
|
||||
|
||||
const hasFiles = files.length > 0;
|
||||
|
||||
@@ -135,16 +183,167 @@ export function FaviconSettings() {
|
||||
{files.length > 1 && format(t.toolSettings.favicon.multipleHint, { count: files.length })}
|
||||
</p>
|
||||
|
||||
{/* Live preview grid */}
|
||||
{hasFiles && blobUrl && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Preview</p>
|
||||
<div className="flex items-end justify-center gap-3">
|
||||
{PREVIEW_BOXES.map((px) => {
|
||||
const insetPx = Math.round((px * padding) / 100);
|
||||
return (
|
||||
<div key={px} className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{
|
||||
width: px,
|
||||
height: px,
|
||||
borderRadius: `${radius}%`,
|
||||
background: bgMode === "color" ? bgColor : "transparent",
|
||||
}}
|
||||
>
|
||||
{bgMode === "transparent" && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg,#0001 25%,transparent 25%),linear-gradient(-45deg,#0001 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#0001 75%),linear-gradient(-45deg,transparent 75%,#0001 75%)",
|
||||
backgroundSize: "8px 8px",
|
||||
backgroundPosition: "0 0,0 4px,4px -4px,-4px 0",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
padding: insetPx,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="select-none block"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{px}px</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Background</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex flex-1 gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgMode("transparent")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${bgMode === "transparent" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Transparent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgMode("color")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${bgMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Color
|
||||
</button>
|
||||
</div>
|
||||
{bgMode === "color" && (
|
||||
<input
|
||||
type="color"
|
||||
value={bgColor}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
aria-label="Background color"
|
||||
className="h-7 w-9 shrink-0 rounded border border-border bg-background"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Padding */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Padding</span>
|
||||
<span className="text-xs font-mono text-foreground">{padding}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={40}
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Radius */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Corner Radius</span>
|
||||
<span className="text-xs font-mono text-foreground">{radius}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>Square</span>
|
||||
<span>Circle</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme color */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Theme Color</span>
|
||||
<input
|
||||
type="color"
|
||||
value={themeColor}
|
||||
onChange={(e) => setThemeColor(e.target.value)}
|
||||
aria-label="Theme color"
|
||||
className="h-7 w-9 shrink-0 rounded border border-border bg-background"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
Used in manifest.json for browser chrome
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Size checklist */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{t.toolSettings.favicon.generatedSizes}
|
||||
</p>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{SIZES.map((s) => (
|
||||
<div key={s.name} className="flex justify-between text-xs text-foreground">
|
||||
<span className="font-mono">{s.name}</span>
|
||||
<span className="text-muted-foreground">{s.size}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{SIZE_OPTIONS.map((s) => (
|
||||
<label key={s.size} className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSizes.has(s.size)}
|
||||
onChange={() => toggleSize(s.size)}
|
||||
className="rounded border-border accent-primary"
|
||||
/>
|
||||
<span className="font-mono text-foreground">{s.name}</span>
|
||||
<span className="text-muted-foreground ms-auto">{s.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function formatKB(bytes: number): string {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
export function GifWebpSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("gif-webp");
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
progress,
|
||||
originalSize,
|
||||
processedSize,
|
||||
} = useToolProcessor("gif-webp");
|
||||
|
||||
const [quality, setQuality] = useState(80);
|
||||
const [lossless, setLossless] = useState(false);
|
||||
const [resizePercent, setResizePercent] = useState(100);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { quality, lossless, resizePercent };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, {});
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, {});
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -33,6 +51,61 @@ export function GifWebpSettings() {
|
||||
determined automatically by the input file format.
|
||||
</p>
|
||||
|
||||
{/* Lossless toggle */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Compression</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLossless(false)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${!lossless ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Lossy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLossless(true)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${lossless ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Lossless
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality slider (hidden when lossless) */}
|
||||
{!lossless && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Quality</span>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resize slider */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Resize</span>
|
||||
<span className="text-xs font-mono text-foreground">{resizePercent}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
value={resizePercent}
|
||||
onChange={(e) => setResizePercent(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
@@ -58,15 +131,22 @@ export function GifWebpSettings() {
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="gif-webp-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
<>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="gif-webp-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
{originalSize != null && processedSize != null && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{formatKB(originalSize)} → {formatKB(processedSize)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,70 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const CHART_W = 280;
|
||||
const CHART_H = 150;
|
||||
|
||||
type ChannelKey = "r" | "g" | "b" | "lum";
|
||||
|
||||
const CHANNEL_META: Record<ChannelKey, { label: string; fill: string; dot: string }> = {
|
||||
r: { label: "R", fill: "rgba(239,68,68,0.5)", dot: "bg-red-500" },
|
||||
g: { label: "G", fill: "rgba(34,197,94,0.5)", dot: "bg-green-500" },
|
||||
b: { label: "B", fill: "rgba(59,130,246,0.5)", dot: "bg-blue-500" },
|
||||
lum: { label: "L", fill: "rgba(120,120,120,0.5)", dot: "bg-neutral-400" },
|
||||
};
|
||||
|
||||
const ALL_CHANNELS: ChannelKey[] = ["r", "g", "b", "lum"];
|
||||
|
||||
function buildPath(bins: number[], maxVal: number, scale: "linear" | "log"): string {
|
||||
if (maxVal === 0) return "";
|
||||
const xStep = CHART_W / 255;
|
||||
const pts: string[] = [`M 0,${CHART_H}`];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const x = (i * xStep).toFixed(1);
|
||||
const v = bins[i];
|
||||
const norm = scale === "log" ? Math.log(1 + v) / Math.log(1 + maxVal) : v / maxVal;
|
||||
const y = (CHART_H - norm * CHART_H).toFixed(1);
|
||||
pts.push(`L ${x},${y}`);
|
||||
}
|
||||
pts.push(`L ${CHART_W},${CHART_H} Z`);
|
||||
return pts.join(" ");
|
||||
}
|
||||
|
||||
type ChannelStats = { mean: number; median: number; stdev: number };
|
||||
|
||||
export function HistogramSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress, resultPayload } =
|
||||
useToolProcessor("histogram");
|
||||
|
||||
const [visible, setVisible] = useState<Record<ChannelKey, boolean>>({
|
||||
r: true,
|
||||
g: true,
|
||||
b: true,
|
||||
lum: false,
|
||||
});
|
||||
const [scale, setScale] = useState<"linear" | "log">("linear");
|
||||
|
||||
const bins = resultPayload?.bins as Record<ChannelKey, number[]> | undefined;
|
||||
const stats = resultPayload?.stats as Record<ChannelKey, ChannelStats> | undefined;
|
||||
|
||||
const maxVal = useMemo(() => {
|
||||
if (!bins) return 0;
|
||||
let m = 0;
|
||||
for (const ch of ALL_CHANNELS) {
|
||||
if (!visible[ch]) continue;
|
||||
for (const v of bins[ch]) {
|
||||
if (v > m) m = v;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [bins, visible]);
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, {});
|
||||
@@ -26,6 +81,10 @@ export function HistogramSettings() {
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
const toggleChannel = (ch: ChannelKey) => {
|
||||
setVisible((prev) => ({ ...prev, [ch]: !prev[ch] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -56,6 +115,98 @@ export function HistogramSettings() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{bins && (
|
||||
<div className="space-y-3" data-testid="histogram-chart">
|
||||
{/* Channel toggles + scale switch */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{ALL_CHANNELS.map((ch) => (
|
||||
<button
|
||||
key={ch}
|
||||
type="button"
|
||||
onClick={() => toggleChannel(ch)}
|
||||
data-testid={`histogram-toggle-${ch}`}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
visible[ch]
|
||||
? "bg-muted text-foreground"
|
||||
: "bg-transparent text-muted-foreground opacity-50"
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${CHANNEL_META[ch].dot}`} />
|
||||
{CHANNEL_META[ch].label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ms-auto flex gap-0.5 rounded bg-muted p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScale("linear")}
|
||||
data-testid="histogram-scale-linear"
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
scale === "linear"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Lin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setScale("log")}
|
||||
data-testid="histogram-scale-log"
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||
scale === "log"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG histogram */}
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
|
||||
className="w-full rounded bg-neutral-900"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Histogram chart"
|
||||
data-testid="histogram-svg"
|
||||
>
|
||||
{ALL_CHANNELS.filter((ch) => visible[ch]).map((ch) => (
|
||||
<path
|
||||
key={ch}
|
||||
d={buildPath(bins[ch], maxVal, scale)}
|
||||
fill={CHANNEL_META[ch].fill}
|
||||
stroke="none"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Stats readout */}
|
||||
{stats && (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-x-4 gap-y-1 font-mono text-[10px] text-muted-foreground"
|
||||
data-testid="histogram-stats"
|
||||
>
|
||||
{ALL_CHANNELS.filter((ch) => visible[ch]).map((ch) => (
|
||||
<div key={ch} className="flex items-center gap-1">
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full ${CHANNEL_META[ch].dot}`}
|
||||
/>
|
||||
<span>
|
||||
{CHANNEL_META[ch].label}: {stats[ch].mean} / {stats[ch].median} /{" "}
|
||||
{stats[ch].stdev}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="col-span-2 text-[9px] text-muted-foreground/60 mt-0.5">
|
||||
mean / median / stdev
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -11,19 +11,145 @@ const TARGET_OPTIONS = [
|
||||
{ value: "1:1", label: "1:1" },
|
||||
{ value: "4:3", label: "4:3" },
|
||||
{ value: "3:4", label: "3:4" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
] as const;
|
||||
|
||||
export function ImagePadSettings() {
|
||||
/** Mirror of the backend canvasFor helper for preview geometry. */
|
||||
function canvasFor(w: number, h: number, target: string): { cw: number; ch: number } {
|
||||
const [tw, th] = target.split(":").map(Number);
|
||||
const ratio = tw / th;
|
||||
const src = w / h;
|
||||
if (src > ratio) return { cw: w, ch: Math.round(w / ratio) };
|
||||
return { cw: Math.round(h * ratio), ch: h };
|
||||
}
|
||||
|
||||
interface ImagePadSettingsProps {
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (node: React.ReactNode) => void;
|
||||
}
|
||||
|
||||
export function ImagePadSettings({ onImageStyle, onImageOverlay }: ImagePadSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const blobUrl = entry?.blobUrl;
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("image-pad");
|
||||
|
||||
const [target, setTarget] = useState("1:1");
|
||||
const [ratioW, setRatioW] = useState(1);
|
||||
const [ratioH, setRatioH] = useState(1);
|
||||
const [background, setBackground] = useState<"color" | "transparent" | "blur">("color");
|
||||
const [color, setColor] = useState("#ffffff");
|
||||
const [padding, setPadding] = useState(0);
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
// Natural image dimensions
|
||||
useEffect(() => {
|
||||
if (!blobUrl) {
|
||||
setDims(null);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => setDims({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.src = blobUrl;
|
||||
}, [blobUrl]);
|
||||
|
||||
// Stable refs for preview callbacks to avoid stale closures
|
||||
const onImageStyleRef = useRef(onImageStyle);
|
||||
useEffect(() => {
|
||||
onImageStyleRef.current = onImageStyle;
|
||||
});
|
||||
const onImageOverlayRef = useRef(onImageOverlay);
|
||||
useEffect(() => {
|
||||
onImageOverlayRef.current = onImageOverlay;
|
||||
});
|
||||
|
||||
// Live preview: padded-canvas overlay covering the pane image.
|
||||
// onImageStyle({}) activates the wrapper branch in image-viewer
|
||||
// so onImageOverlay children actually mount.
|
||||
useEffect(() => {
|
||||
if (!blobUrl || !dims) return;
|
||||
|
||||
onImageStyleRef.current?.({});
|
||||
|
||||
const w = dims.w;
|
||||
const h = dims.h;
|
||||
const ratioStr = target === "custom" ? `${ratioW}:${ratioH}` : target;
|
||||
const { cw, ch } = canvasFor(w, h, ratioStr);
|
||||
const margin = padding > 0 ? Math.round((Math.max(cw, ch) * padding) / 100) : 0;
|
||||
const finalW = cw + margin * 2;
|
||||
const finalH = ch + margin * 2;
|
||||
|
||||
// Fit the canvas (which may have a different AR) inside the overlay
|
||||
// (which has the source image AR).
|
||||
const canvasAR = finalW / finalH;
|
||||
const overlayAR = w / h;
|
||||
const [canvasWPct, canvasHPct] =
|
||||
canvasAR > overlayAR
|
||||
? [100, (overlayAR / canvasAR) * 100]
|
||||
: [(canvasAR / overlayAR) * 100, 100];
|
||||
|
||||
// Image positioning within the canvas
|
||||
const imgWPct = (w / finalW) * 100;
|
||||
const imgHPct = (h / finalH) * 100;
|
||||
const imgLeftPct = ((finalW - w) / (2 * finalW)) * 100;
|
||||
const imgTopPct = ((finalH - h) / (2 * finalH)) * 100;
|
||||
|
||||
const checkerStyle: React.CSSProperties = {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg,#ccc 25%,transparent 25%),linear-gradient(-45deg,#ccc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ccc 75%),linear-gradient(-45deg,transparent 75%,#ccc 75%)",
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0,0 8px,8px -8px,-8px 0",
|
||||
backgroundColor: "#fff",
|
||||
};
|
||||
|
||||
const bgStyle: React.CSSProperties =
|
||||
background === "transparent"
|
||||
? checkerStyle
|
||||
: background === "color"
|
||||
? { backgroundColor: color }
|
||||
: {};
|
||||
|
||||
onImageOverlayRef.current?.(
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: `${canvasWPct}%`, height: `${canvasHPct}%`, ...bgStyle }}
|
||||
>
|
||||
{background === "blur" && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute inset-0 h-full w-full select-none"
|
||||
style={{ objectFit: "cover", filter: "blur(20px)", transform: "scale(1.1)" }}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="absolute select-none"
|
||||
style={{
|
||||
width: `${imgWPct}%`,
|
||||
height: `${imgHPct}%`,
|
||||
left: `${imgLeftPct}%`,
|
||||
top: `${imgTopPct}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
return () => {
|
||||
onImageStyleRef.current?.(null);
|
||||
onImageOverlayRef.current?.(null);
|
||||
};
|
||||
}, [blobUrl, dims, target, ratioW, ratioH, background, color, padding]);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { target, color };
|
||||
const settings = { target, ratioW, ratioH, background, color, padding };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -41,47 +167,109 @@ export function ImagePadSettings() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Target Ratio */}
|
||||
{/* Aspect Ratio */}
|
||||
<div>
|
||||
<label htmlFor="image-pad-target" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["image-pad"].target}
|
||||
</label>
|
||||
<select
|
||||
id="image-pad-target"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">{t.toolSettings["image-pad"].target}</p>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{TARGET_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setTarget(opt.value)}
|
||||
className={`text-xs py-1.5 rounded transition-colors ${
|
||||
target === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</option>
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{target === "custom" && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={ratioW}
|
||||
onChange={(e) => setRatioW(Math.max(1, Math.min(100, Number(e.target.value) || 1)))}
|
||||
className="w-16 px-2 py-1 rounded border border-border bg-background text-sm text-foreground text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={ratioH}
|
||||
onChange={(e) => setRatioH(Math.max(1, Math.min(100, Number(e.target.value) || 1)))}
|
||||
className="w-16 px-2 py-1 rounded border border-border bg-background text-sm text-foreground text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Background Color */}
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
<label htmlFor="image-pad-color" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["image-pad"].color}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="image-pad-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mb-1.5">Background</p>
|
||||
<div className="flex gap-1">
|
||||
{(["color", "transparent", "blur"] as const).map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
type="button"
|
||||
onClick={() => setBackground(bg)}
|
||||
className={`flex-1 text-xs py-1.5 rounded transition-colors ${
|
||||
background === bg
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
{bg === "color" ? "Color" : bg === "transparent" ? "Transparent" : "Blur"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Picker (only for "color" background) */}
|
||||
{background === "color" && (
|
||||
<div>
|
||||
<label htmlFor="image-pad-color" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["image-pad"].color}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<input
|
||||
id="image-pad-color"
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border shrink-0"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Padding Slider */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Padding</span>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums">{padding}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Strategy = "blur" | "pixelate" | "solid";
|
||||
type Format = "webp" | "png" | "jpeg";
|
||||
|
||||
export function LqipPlaceholderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress, resultPayload } =
|
||||
useToolProcessor("lqip-placeholder");
|
||||
|
||||
const [width, setWidth] = useState(16);
|
||||
const [blur, setBlur] = useState(2);
|
||||
const [strategy, setStrategy] = useState<Strategy>("blur");
|
||||
const [format, setFormat] = useState<Format>("webp");
|
||||
const [quality, setQuality] = useState(50);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { width, blur };
|
||||
const settings = { width, blur, strategy, format, quality };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -31,8 +39,44 @@ export function LqipPlaceholderSettings() {
|
||||
if (canProcess) handleProcess();
|
||||
};
|
||||
|
||||
const handleCopy = async (text: string, label: string) => {
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) {
|
||||
setCopied(label);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const dataUri = resultPayload?.dataUri as string | undefined;
|
||||
const resultWidth = resultPayload?.width as number | undefined;
|
||||
const resultHeight = resultPayload?.height as number | undefined;
|
||||
const resultBytes = resultPayload?.bytes as number | undefined;
|
||||
const resultHtml = resultPayload?.html as string | undefined;
|
||||
const resultCss = resultPayload?.css as string | undefined;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Strategy */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Strategy</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{(["blur", "pixelate", "solid"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setStrategy(s)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
strategy === s
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Width */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -52,28 +96,65 @@ export function LqipPlaceholderSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blur */}
|
||||
{/* Blur (only for blur strategy) */}
|
||||
{strategy === "blur" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="lqip-blur" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["lqip-placeholder"].blur}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{blur}</span>
|
||||
</div>
|
||||
<input
|
||||
id="lqip-blur"
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={blur}
|
||||
onChange={(e) => setBlur(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="lqip-blur" className="text-xs text-muted-foreground">
|
||||
{t.toolSettings["lqip-placeholder"].blur}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{blur}</span>
|
||||
<span className="text-xs text-muted-foreground">Format</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{(["webp", "png", "jpeg"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFormat(f)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
format === f
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{f === "webp" ? "WebP" : f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
id="lqip-blur"
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={blur}
|
||||
onChange={(e) => setBlur(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
The base64 data URI will appear in the result envelope.
|
||||
</p>
|
||||
{/* Quality (only for webp/jpeg) */}
|
||||
{format !== "png" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Quality</span>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
@@ -113,6 +194,86 @@ export function LqipPlaceholderSettings() {
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Output section */}
|
||||
{dataUri && (
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
{/* Preview + stats */}
|
||||
<div className="flex items-start gap-3">
|
||||
<img
|
||||
src={dataUri}
|
||||
alt="LQIP preview"
|
||||
className="rounded border border-border"
|
||||
style={{ width: 96, imageRendering: "auto" }}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>
|
||||
{resultWidth} x {resultHeight} px
|
||||
</p>
|
||||
<p>{resultBytes} bytes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data URI */}
|
||||
<CopyBlock
|
||||
label="Data URI"
|
||||
value={dataUri}
|
||||
copied={copied === "dataUri"}
|
||||
onCopy={() => handleCopy(dataUri, "dataUri")}
|
||||
/>
|
||||
|
||||
{/* HTML snippet */}
|
||||
{resultHtml && (
|
||||
<CopyBlock
|
||||
label="HTML"
|
||||
value={resultHtml}
|
||||
copied={copied === "html"}
|
||||
onCopy={() => handleCopy(resultHtml, "html")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* CSS snippet */}
|
||||
{resultCss && (
|
||||
<CopyBlock
|
||||
label="CSS"
|
||||
value={resultCss}
|
||||
copied={copied === "css"}
|
||||
onCopy={() => handleCopy(resultCss, "css")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyBlock({
|
||||
label,
|
||||
value,
|
||||
copied,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-muted text-xs font-mono text-foreground overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Copy, FileText } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { DocumentView } from "./document-view";
|
||||
|
||||
/**
|
||||
* Results panel for the OCR PDF tool: the original PDF on one side and the
|
||||
* extracted text on the other. The tool's output is a .txt transcript, so the
|
||||
* PDF preview is forced to the input (inputOnly) and the text is fetched from
|
||||
* the processed download URL once OCR completes.
|
||||
*/
|
||||
export function OcrPdfView() {
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const processedUrl = entry?.processedUrl ?? null;
|
||||
const status = entry?.status;
|
||||
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!processedUrl) {
|
||||
setText(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetch(processedUrl)
|
||||
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((t) => {
|
||||
if (!cancelled) setText(t);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setText(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [processedUrl]);
|
||||
|
||||
const copy = async () => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// clipboard unavailable; ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-3 overflow-hidden p-3 lg:flex-row">
|
||||
{/* Original PDF */}
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-border">
|
||||
<DocumentView inputOnly />
|
||||
</div>
|
||||
|
||||
{/* Extracted text */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-sm font-medium text-foreground">Extracted Text</span>
|
||||
{text != null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading extracted text...</p>
|
||||
) : text != null ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-foreground">
|
||||
{text || "(no text found)"}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-center">
|
||||
<FileText className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="max-w-xs text-sm text-muted-foreground">
|
||||
{status === "processing"
|
||||
? "Extracting text..."
|
||||
: "The extracted text will appear here after you run OCR."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,181 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function PixelateSettings() {
|
||||
type PixelateMode = "whole" | "selection";
|
||||
|
||||
export function PixelateSettings({
|
||||
onImageOverlay,
|
||||
onImageStyle,
|
||||
}: {
|
||||
onImageOverlay?: (children: React.ReactNode) => void;
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const entry = useFileStore((s) => s.entries[s.selectedIndex]);
|
||||
const blobUrl = entry?.blobUrl;
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
useToolProcessor("pixelate");
|
||||
|
||||
const [blockSize, setBlockSize] = useState(12);
|
||||
const [mode, setMode] = useState<PixelateMode>("whole");
|
||||
|
||||
// Natural image dimensions (for converting normalized box to pixel region)
|
||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!blobUrl) {
|
||||
setDims(null);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => setDims({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
img.src = blobUrl;
|
||||
}, [blobUrl]);
|
||||
|
||||
// Normalized selection box in 0..1 (default centered 40x40%)
|
||||
const [boxX, setBoxX] = useState(0.3);
|
||||
const [boxY, setBoxY] = useState(0.3);
|
||||
const [boxW, setBoxW] = useState(0.4);
|
||||
const [boxH, setBoxH] = useState(0.4);
|
||||
|
||||
// Refs for latest values (stable pointer-event handlers read from these)
|
||||
const boxXRef = useRef(boxX);
|
||||
const boxYRef = useRef(boxY);
|
||||
const boxWRef = useRef(boxW);
|
||||
const boxHRef = useRef(boxH);
|
||||
useEffect(() => {
|
||||
boxXRef.current = boxX;
|
||||
}, [boxX]);
|
||||
useEffect(() => {
|
||||
boxYRef.current = boxY;
|
||||
}, [boxY]);
|
||||
useEffect(() => {
|
||||
boxWRef.current = boxW;
|
||||
}, [boxW]);
|
||||
useEffect(() => {
|
||||
boxHRef.current = boxH;
|
||||
}, [boxH]);
|
||||
|
||||
const dragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
origX: number;
|
||||
origY: number;
|
||||
} | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Ref-to-latest for overlay/style callbacks (avoids stale closures)
|
||||
const onOverlayRef = useRef(onImageOverlay);
|
||||
useEffect(() => {
|
||||
onOverlayRef.current = onImageOverlay;
|
||||
});
|
||||
const onStyleRef = useRef(onImageStyle);
|
||||
useEffect(() => {
|
||||
onStyleRef.current = onImageStyle;
|
||||
});
|
||||
|
||||
// Stable pointer-event handlers (read state from refs, never go stale)
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: boxXRef.current,
|
||||
origY: boxYRef.current,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const dx = (e.clientX - dragRef.current.startX) / rect.width;
|
||||
const dy = (e.clientY - dragRef.current.startY) / rect.height;
|
||||
const nx = Math.min(Math.max(dragRef.current.origX + dx, 0), 1 - boxWRef.current);
|
||||
const ny = Math.min(Math.max(dragRef.current.origY + dy, 0), 1 - boxHRef.current);
|
||||
setBoxX(nx);
|
||||
setBoxY(ny);
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
dragRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Clamp box position when width/height sliders change
|
||||
useEffect(() => {
|
||||
setBoxX((prev) => Math.min(prev, 1 - boxW));
|
||||
setBoxY((prev) => Math.min(prev, 1 - boxH));
|
||||
}, [boxW, boxH]);
|
||||
|
||||
// Manage overlay and imageWrapperStyle based on mode
|
||||
useEffect(() => {
|
||||
if (mode !== "selection") {
|
||||
onOverlayRef.current?.(null);
|
||||
onStyleRef.current?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Activate the image wrapper div so overlay children render
|
||||
onStyleRef.current?.({});
|
||||
|
||||
const overlay = (
|
||||
<div
|
||||
ref={(el: HTMLDivElement | null) => {
|
||||
containerRef.current = el;
|
||||
}}
|
||||
style={{
|
||||
position: "absolute" as const,
|
||||
inset: 0,
|
||||
zIndex: 10,
|
||||
pointerEvents: "none" as const,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="pixelate-selection-box"
|
||||
style={{
|
||||
position: "absolute" as const,
|
||||
left: `${boxX * 100}%`,
|
||||
top: `${boxY * 100}%`,
|
||||
width: `${boxW * 100}%`,
|
||||
height: `${boxH * 100}%`,
|
||||
border: "2px solid hsl(var(--primary))",
|
||||
background: "hsl(var(--primary) / 0.08)",
|
||||
boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.4)",
|
||||
cursor: "move",
|
||||
touchAction: "none" as const,
|
||||
pointerEvents: "auto" as const,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
onOverlayRef.current?.(overlay);
|
||||
|
||||
return () => {
|
||||
onOverlayRef.current?.(null);
|
||||
onStyleRef.current?.(null);
|
||||
};
|
||||
}, [mode, boxX, boxY, boxW, boxH, handlePointerDown, handlePointerMove, handlePointerUp]);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { blockSize };
|
||||
const settings: Record<string, unknown> = { blockSize };
|
||||
if (mode === "selection" && dims) {
|
||||
const W = dims.w;
|
||||
const H = dims.h;
|
||||
const left = Math.round(boxX * W);
|
||||
const top = Math.round(boxY * H);
|
||||
const width = Math.max(1, Math.min(Math.round(boxW * W), W - left));
|
||||
const height = Math.max(1, Math.min(Math.round(boxH * H), H - top));
|
||||
settings.region = { left, top, width, height };
|
||||
}
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -32,6 +193,37 @@ export function PixelateSettings() {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Mode</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pixelate-mode-whole"
|
||||
onClick={() => setMode("whole")}
|
||||
className={`flex-1 text-xs py-1.5 rounded transition-colors ${
|
||||
mode === "whole"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Whole image
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pixelate-mode-selection"
|
||||
onClick={() => setMode("selection")}
|
||||
className={`flex-1 text-xs py-1.5 rounded transition-colors ${
|
||||
mode === "selection"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block Size */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -49,11 +241,60 @@ export function PixelateSettings() {
|
||||
onChange={(e) => setBlockSize(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Applies pixelation to the full image
|
||||
</p>
|
||||
{mode === "whole" && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Applies pixelation to the full image
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection region controls */}
|
||||
{mode === "selection" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="pixelate-region-width" className="text-xs text-muted-foreground">
|
||||
Width
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{Math.round(boxW * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="pixelate-region-width"
|
||||
data-testid="pixelate-region-width"
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
value={Math.round(boxW * 100)}
|
||||
onChange={(e) => setBoxW(Number(e.target.value) / 100)}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="pixelate-region-height" className="text-xs text-muted-foreground">
|
||||
Height
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{Math.round(boxH * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="pixelate-region-height"
|
||||
data-testid="pixelate-region-height"
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
value={Math.round(boxH * 100)}
|
||||
onChange={(e) => setBoxH(Number(e.target.value) / 100)}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
{hasFile && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Drag the selection box on the image to reposition
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
|
||||
@@ -1,27 +1,40 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { Check, ClipboardCopy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { copyToClipboard } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type OutputFormat = "png" | "webp" | "jpeg";
|
||||
|
||||
interface Frame {
|
||||
index: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function SpriteSheetSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
const { processFiles, processing, error, downloadUrl, progress, resultPayload } =
|
||||
useToolProcessor("sprite-sheet");
|
||||
|
||||
const [columns, setColumns] = useState(4);
|
||||
const [padding, setPadding] = useState(0);
|
||||
const [background, setBackground] = useState("#ffffff");
|
||||
const [format, setFormat] = useState<OutputFormat>("png");
|
||||
const [quality, setQuality] = useState(90);
|
||||
const [copiedExport, setCopiedExport] = useState<"css" | "json" | null>(null);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { columns, padding, background };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
const settings = { columns, padding, background, format, quality };
|
||||
// sprite-sheet packs every image into ONE sheet, so all files go in a
|
||||
// single request. It is a MULTI_FILE tool, so processFiles appends them
|
||||
// all; processAllFiles would wrongly fan out to the per-file batch route.
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
@@ -94,6 +107,47 @@ export function SpriteSheetSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format */}
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Format</span>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{(["png", "webp", "jpeg"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
onClick={() => setFormat(f)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
format === f
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
data-testid={`sprite-sheet-format-${f}`}
|
||||
>
|
||||
{f === "webp" ? "WebP" : f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality (webp/jpeg only) */}
|
||||
{format !== "png" && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Quality</span>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
data-testid="sprite-sheet-quality"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{processing ? (
|
||||
@@ -127,6 +181,104 @@ export function SpriteSheetSettings() {
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Coordinate map output */}
|
||||
{Array.isArray(resultPayload?.frames) && (
|
||||
<SpriteOutput
|
||||
payload={resultPayload}
|
||||
format={format}
|
||||
copiedExport={copiedExport}
|
||||
setCopiedExport={setCopiedExport}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SpriteOutput({
|
||||
payload,
|
||||
format,
|
||||
copiedExport,
|
||||
setCopiedExport,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
format: OutputFormat;
|
||||
copiedExport: "css" | "json" | null;
|
||||
setCopiedExport: (v: "css" | "json" | null) => void;
|
||||
}) {
|
||||
const frames = payload.frames as Frame[];
|
||||
const cols = payload.cols as number;
|
||||
const rows = payload.rows as number;
|
||||
const cellWidth = payload.cellWidth as number;
|
||||
const cellHeight = payload.cellHeight as number;
|
||||
const canvasWidth = payload.canvasWidth as number;
|
||||
const canvasHeight = payload.canvasHeight as number;
|
||||
|
||||
const ext = format === "jpeg" ? "jpg" : format;
|
||||
|
||||
const copyCss = async () => {
|
||||
const base = `.sprite {\n background-image: url('sprite.${ext}');\n background-repeat: no-repeat;\n display: inline-block;\n}`;
|
||||
const rules = frames
|
||||
.map(
|
||||
(f) =>
|
||||
`.sprite-${f.index} {\n width: ${f.width}px;\n height: ${f.height}px;\n background-position: -${f.left}px -${f.top}px;\n}`,
|
||||
)
|
||||
.join("\n");
|
||||
const ok = await copyToClipboard(`${base}\n${rules}`);
|
||||
if (ok) {
|
||||
setCopiedExport("css");
|
||||
setTimeout(() => setCopiedExport(null), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const copyJson = async () => {
|
||||
const ok = await copyToClipboard(
|
||||
JSON.stringify(
|
||||
{ frames, cols, rows, cellWidth, cellHeight, canvasWidth, canvasHeight },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (ok) {
|
||||
setCopiedExport("json");
|
||||
setTimeout(() => setCopiedExport(null), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-2 border-t border-border" data-testid="sprite-sheet-output">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cols} x {rows} grid · {cellWidth} x {cellHeight}px cells · {canvasWidth} x {canvasHeight}px
|
||||
canvas
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCss}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-muted-foreground hover:bg-muted transition-colors"
|
||||
data-testid="sprite-sheet-copy-css"
|
||||
>
|
||||
{copiedExport === "css" ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-3 w-3" />
|
||||
)}
|
||||
Copy CSS
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyJson}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-muted-foreground hover:bg-muted transition-colors"
|
||||
data-testid="sprite-sheet-copy-json"
|
||||
>
|
||||
{copiedExport === "json" ? (
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-3 w-3" />
|
||||
)}
|
||||
Copy JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
export function VignetteSettings() {
|
||||
export function VignetteSettings({
|
||||
onImageStyle,
|
||||
onImageOverlay,
|
||||
}: {
|
||||
onImageStyle?: (style: React.CSSProperties | null) => void;
|
||||
onImageOverlay?: (node: React.ReactNode) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||
@@ -13,9 +19,59 @@ export function VignetteSettings() {
|
||||
|
||||
const [strength, setStrength] = useState(0.5);
|
||||
const [color, setColor] = useState("#000000");
|
||||
const [radius, setRadius] = useState(70);
|
||||
const [softness, setSoftness] = useState(50);
|
||||
const [roundness, setRoundness] = useState(100);
|
||||
const [centerX, setCenterX] = useState(50);
|
||||
const [centerY, setCenterY] = useState(50);
|
||||
|
||||
// Stable refs for the preview callbacks to avoid re-render loops
|
||||
const onOverlayRef = useRef(onImageOverlay);
|
||||
useEffect(() => {
|
||||
onOverlayRef.current = onImageOverlay;
|
||||
});
|
||||
const onStyleRef = useRef(onImageStyle);
|
||||
useEffect(() => {
|
||||
onStyleRef.current = onImageStyle;
|
||||
});
|
||||
|
||||
// Live preview overlay via onImageOverlay
|
||||
useEffect(() => {
|
||||
const outerR = radius / 100;
|
||||
const innerStop = Math.max(0, Math.min(1, outerR * (1 - softness / 100)));
|
||||
const innerPct = (innerStop * 100).toFixed(1);
|
||||
|
||||
const shape = roundness === 100 ? "circle" : "ellipse";
|
||||
const gradientColor = `${color}`;
|
||||
|
||||
const bg = `radial-gradient(${shape} at ${centerX}% ${centerY}%, transparent ${innerPct}%, ${gradientColor} ${(outerR * 100).toFixed(1)}%)`;
|
||||
|
||||
const overlay = (
|
||||
<div
|
||||
data-testid="vignette-overlay"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: bg,
|
||||
opacity: strength,
|
||||
pointerEvents: "none",
|
||||
borderRadius: "inherit",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// onImageStyle activates the wrapper branch in image-viewer so the overlay
|
||||
// child actually mounts; without it the overlay node is never rendered.
|
||||
onStyleRef.current?.({});
|
||||
onOverlayRef.current?.(overlay);
|
||||
return () => {
|
||||
onStyleRef.current?.(null);
|
||||
onOverlayRef.current?.(null);
|
||||
};
|
||||
}, [strength, color, radius, softness, roundness, centerX, centerY]);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { strength, color };
|
||||
const settings = { strength, color, radius, softness, roundness, centerX, centerY };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
@@ -53,6 +109,106 @@ export function VignetteSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Radius */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="vignette-radius" className="text-xs text-muted-foreground">
|
||||
Radius
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{radius}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="vignette-radius"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Softness */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="vignette-softness" className="text-xs text-muted-foreground">
|
||||
Softness
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{softness}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="vignette-softness"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={softness}
|
||||
onChange={(e) => setSoftness(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Roundness */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="vignette-roundness" className="text-xs text-muted-foreground">
|
||||
Roundness
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{roundness}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="vignette-roundness"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={roundness}
|
||||
onChange={(e) => setRoundness(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center X */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="vignette-centerX" className="text-xs text-muted-foreground">
|
||||
Center X
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{centerX}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="vignette-centerX"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={centerX}
|
||||
onChange={(e) => setCenterX(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center Y */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor="vignette-centerY" className="text-xs text-muted-foreground">
|
||||
Center Y
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{centerY}%</span>
|
||||
</div>
|
||||
<input
|
||||
id="vignette-centerY"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={centerY}
|
||||
onChange={(e) => setCenterY(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Vignette Color */}
|
||||
<div>
|
||||
<label htmlFor="vignette-color" className="text-xs text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user