From 7e4ebe427c8c478261718643512394982230aa75 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:44:20 +0200 Subject: [PATCH 01/15] refactor: new fft editor --- src/components/FftEditor.tsx | 447 ----------- .../components/SidebarAdjustments.tsx | 76 ++ .../edit-window/components/SidebarFFT.tsx | 182 +++++ .../edit-window/components/SidebarTools.tsx | 61 ++ src/components/edit-window/edit-window.tsx | 754 ++++-------------- src/components/edit-window/fft/ImagePanes.tsx | 241 ++++++ .../edit-window/fft/fftCanvasUtils.ts | 39 + src/components/edit-window/fft/fftTypes.ts | 3 + .../edit-window/fft/image-fft-controls.tsx | 485 ----------- src/components/edit-window/fft/useFftInit.ts | 140 ++++ .../edit-window/fft/useFftPainter.ts | 226 ++++++ .../edit-window/fft/useFftWorkspace.ts | 186 +++++ .../edit-window/hooks/useElementSync.ts | 90 +++ .../edit-window/hooks/useImageIO.ts | 238 ++++++ .../edit-window/hooks/useImagePanZoom.ts | 93 +++ src/lib/fftProcessor.ts | 182 +++-- src/lib/locales/en/keywords.ts | 6 + src/lib/locales/pl/keywords.ts | 6 + src/lib/locales/translation.ts | 6 + 19 files changed, 1882 insertions(+), 1579 deletions(-) delete mode 100644 src/components/FftEditor.tsx create mode 100644 src/components/edit-window/components/SidebarAdjustments.tsx create mode 100644 src/components/edit-window/components/SidebarFFT.tsx create mode 100644 src/components/edit-window/components/SidebarTools.tsx create mode 100644 src/components/edit-window/fft/ImagePanes.tsx create mode 100644 src/components/edit-window/fft/fftCanvasUtils.ts create mode 100644 src/components/edit-window/fft/fftTypes.ts delete mode 100644 src/components/edit-window/fft/image-fft-controls.tsx create mode 100644 src/components/edit-window/fft/useFftInit.ts create mode 100644 src/components/edit-window/fft/useFftPainter.ts create mode 100644 src/components/edit-window/fft/useFftWorkspace.ts create mode 100644 src/components/edit-window/hooks/useElementSync.ts create mode 100644 src/components/edit-window/hooks/useImageIO.ts create mode 100644 src/components/edit-window/hooks/useImagePanZoom.ts diff --git a/src/components/FftEditor.tsx b/src/components/FftEditor.tsx deleted file mode 100644 index 855b64bc..00000000 --- a/src/components/FftEditor.tsx +++ /dev/null @@ -1,447 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; -import { ImageFFT, type FFTResult } from "@/lib/fftProcessor"; -import { save } from "@tauri-apps/plugin-dialog"; -import { writeFile } from "@tauri-apps/plugin-fs"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; - -type Status = "loading" | "ready" | "processing" | "error"; -type ViewMode = "edit" | "preview"; - -interface FftEditorProps { - imageSrc: string; - onClose: () => void; - onSave: (newImageSrc: string) => void; -} - -export function FftEditor({ imageSrc, onClose, onSave }: FftEditorProps) { - const { t } = useTranslation(); - - const canvasRef = useRef(null); - const overlayRef = useRef(null); - - const [fftData, setFftData] = useState(null); - const [processor, setProcessor] = useState(null); - const [originalDims, setOriginalDims] = useState({ w: 0, h: 0 }); - - const [isDrawing, setIsDrawing] = useState(false); - const [status, setStatus] = useState("loading"); - const [errorMsg, setErrorMsg] = useState(""); - const [brushSize, setBrushSize] = useState(30); - const [viewMode, setViewMode] = useState("edit"); - const [savedMaskState, setSavedMaskState] = useState( - null - ); - const [cursorPos, setCursorPos] = useState({ x: -100, y: -100 }); - - // --- Load image & compute FFT --- - useEffect(() => { - setStatus("loading"); - const img = new Image(); - img.crossOrigin = "Anonymous"; - img.src = imageSrc; - img.onload = () => { - setOriginalDims({ w: img.width, h: img.height }); - setTimeout(() => { - try { - const tempCanvas = document.createElement("canvas"); - tempCanvas.width = img.width; - tempCanvas.height = img.height; - const ctx = tempCanvas.getContext("2d", { - willReadFrequently: true, - }); - if (!ctx) throw new Error("Canvas context unavailable"); - ctx.drawImage(img, 0, 0); - const imageData = ctx.getImageData( - 0, - 0, - img.width, - img.height - ); - const proc = new ImageFFT(img.width, img.height); - const result = proc.forward(imageData); - setProcessor(proc); - setFftData(result); - setStatus("ready"); - - if (canvasRef.current) { - canvasRef.current.width = result.width; - canvasRef.current.height = result.height; - const displayCtx = canvasRef.current.getContext("2d", { - willReadFrequently: true, - }); - if (displayCtx) { - displayCtx.fillStyle = "black"; - displayCtx.fillRect( - 0, - 0, - result.width, - result.height - ); - const spectrumImg = new ImageData( - new Uint8ClampedArray(result.spectrum.buffer), - result.width, - result.height - ); - displayCtx.putImageData(spectrumImg, 0, 0); - } - } - if (overlayRef.current) { - overlayRef.current.width = result.width; - overlayRef.current.height = result.height; - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - setErrorMsg( - t("Memory error processing high-res image", { - ns: "dialog", - }) - ); - setStatus("error"); - } - }, 100); - }; - }, [imageSrc, t]); - - // --- Coordinate helpers --- - const getCoords = useCallback((e: React.MouseEvent) => { - if (!canvasRef.current) return { x: 0, y: 0 }; - const rect = canvasRef.current.getBoundingClientRect(); - const scaleX = canvasRef.current.width / rect.width; - const scaleY = canvasRef.current.height / rect.height; - return { - x: (e.clientX - rect.left) * scaleX, - y: (e.clientY - rect.top) * scaleY, - }; - }, []); - - // --- Drawing --- - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - if (status !== "ready" || viewMode !== "edit") return; - const { x, y } = getCoords(e); - setCursorPos({ x, y }); - - if (isDrawing && overlayRef.current) { - const ctx = overlayRef.current.getContext("2d", { - willReadFrequently: true, - }); - if (ctx) { - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = "#c00000"; - ctx.beginPath(); - ctx.arc(x, y, brushSize, 0, Math.PI * 2); - ctx.fill(); - } - } - }, - [status, viewMode, isDrawing, brushSize, getCoords] - ); - - // --- Cursor crosshair rendering --- - useEffect(() => { - if (!overlayRef.current || status !== "ready" || viewMode !== "edit") - return; - const ctx = overlayRef.current.getContext("2d"); - if (!ctx) return; - - if (!isDrawing) { - if (savedMaskState) ctx.putImageData(savedMaskState, 0, 0); - else - ctx.clearRect( - 0, - 0, - overlayRef.current.width, - overlayRef.current.height - ); - - ctx.beginPath(); - ctx.strokeStyle = "#22c55e"; - ctx.lineWidth = 2; - ctx.arc(cursorPos.x, cursorPos.y, brushSize, 0, Math.PI * 2); - ctx.stroke(); - - ctx.beginPath(); - ctx.strokeStyle = "rgba(34, 197, 94, 0.5)"; - ctx.lineWidth = 1; - ctx.moveTo(cursorPos.x - 5, cursorPos.y); - ctx.lineTo(cursorPos.x + 5, cursorPos.y); - ctx.moveTo(cursorPos.x, cursorPos.y - 5); - ctx.lineTo(cursorPos.x, cursorPos.y + 5); - ctx.stroke(); - } - }, [cursorPos, brushSize, status, viewMode, isDrawing, savedMaskState]); - - // --- Mouse up: persist mask --- - const handleMouseUp = useCallback(() => { - setIsDrawing(false); - if (overlayRef.current && viewMode === "edit") { - const ctx = overlayRef.current.getContext("2d"); - if (ctx) { - setSavedMaskState( - ctx.getImageData( - 0, - 0, - overlayRef.current.width, - overlayRef.current.height - ) - ); - } - } - }, [viewMode]); - - // --- Toggle edit/preview --- - const togglePreview = useCallback(() => { - if (!canvasRef.current || !overlayRef.current || !processor || !fftData) - return; - const baseCtx = canvasRef.current.getContext("2d"); - const overlayCtx = overlayRef.current.getContext("2d"); - if (!baseCtx || !overlayCtx) return; - - if (viewMode === "edit") { - const currentMask = overlayCtx.getImageData( - 0, - 0, - fftData.width, - fftData.height - ); - setSavedMaskState(currentMask); - overlayCtx.clearRect( - 0, - 0, - overlayRef.current.width, - overlayRef.current.height - ); - - setStatus("processing"); - setTimeout(() => { - const filteredData = processor.applyMask( - fftData.complexData, - currentMask.data - ); - const resultImage = processor.inverse( - filteredData, - fftData.width, - fftData.height - ); - baseCtx.putImageData(resultImage, 0, 0); - setViewMode("preview"); - setStatus("ready"); - }, 50); - } else { - const spectrumImg = new ImageData( - new Uint8ClampedArray(fftData.spectrum.buffer), - fftData.width, - fftData.height - ); - baseCtx.putImageData(spectrumImg, 0, 0); - - if (savedMaskState) { - overlayCtx.putImageData(savedMaskState, 0, 0); - } - setViewMode("edit"); - } - }, [processor, fftData, viewMode, savedMaskState]); - - // --- Save file --- - const saveFile = useCallback(async () => { - if (!processor || !fftData || !overlayRef.current) return; - - let maskData: Uint8ClampedArray | null = null; - if (savedMaskState) { - maskData = savedMaskState.data; - } else if (overlayRef.current) { - const ctx = overlayRef.current.getContext("2d"); - maskData = - ctx?.getImageData(0, 0, fftData.width, fftData.height).data ?? - null; - } - if (!maskData) return; - - setStatus("processing"); - await new Promise(resolve => { - setTimeout(resolve, 50); - }); - - try { - const filteredComplexData = processor.applyMask( - fftData.complexData, - maskData - ); - const resultImage = processor.inverse( - filteredComplexData, - originalDims.w, - originalDims.h - ); - const outCanvas = document.createElement("canvas"); - outCanvas.width = originalDims.w; - outCanvas.height = originalDims.h; - outCanvas.getContext("2d")?.putImageData(resultImage, 0, 0); - const dataUrl = outCanvas.toDataURL("image/png"); - - const filePath = await save({ - filters: [{ name: "PNG Image", extensions: ["png"] }], - defaultPath: "filtered_image.png", - title: t("Save result as...", { ns: "dialog" }), - }); - if (!filePath) { - setStatus("ready"); - return; - } - - const base64Data = dataUrl.split(",")[1] ?? ""; - const binaryString = atob(base64Data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i += 1) - bytes[i] = binaryString.charCodeAt(i); - await writeFile(filePath, bytes); - onSave(dataUrl); - onClose(); - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - setStatus("ready"); - } - }, [processor, fftData, savedMaskState, originalDims, onSave, onClose, t]); - - // --- Render --- - return ( -
-

- {viewMode === "edit" - ? t("FFT Spectrum Editor", { ns: "keywords" }) - : t("FFT Result Preview", { ns: "keywords" })} -

- -
- {status === "loading" && ( - - {t("Loading...", { ns: "keywords" })} - - )} - {status === "processing" && ( - - {t("Processing...", { ns: "keywords" })} - - )} - {status === "error" && ( - - {errorMsg} - - )} - {status === "ready" && viewMode === "edit" && ( - - {t("Paint over bright spots to filter them out", { - ns: "tooltip", - })} - - )} - {status === "ready" && viewMode === "preview" && ( - - {t("Preview ready. Return to edit or save.", { - ns: "tooltip", - })} - - )} -
- - {viewMode === "edit" && status === "ready" && ( -
- - {t("Brush size", { ns: "keywords" })}: - - - setBrushSize(parseInt(e.target.value, 10)) - } - className="h-2 w-48 cursor-pointer appearance-none rounded-lg bg-muted accent-primary" - /> -
-
- )} - -
- - setIsDrawing(true)} - onMouseUp={handleMouseUp} - onMouseLeave={handleMouseUp} - onMouseMove={handleMouseMove} - style={{ - width: "100%", - height: "100%", - objectFit: "contain", - position: "absolute", - top: 0, - left: 0, - zIndex: 2, - cursor: - status === "ready" && viewMode === "edit" - ? "none" - : "default", - }} - /> -
- -
- - - -
-
- ); -} diff --git a/src/components/edit-window/components/SidebarAdjustments.tsx b/src/components/edit-window/components/SidebarAdjustments.tsx new file mode 100644 index 00000000..44e51468 --- /dev/null +++ b/src/components/edit-window/components/SidebarAdjustments.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; + +interface SidebarAdjustmentsProps { + brightness: number; + setBrightness: (val: number) => void; + contrast: number; + setContrast: (val: number) => void; + disabled: boolean; +} + +export function SidebarAdjustments({ + brightness, + setBrightness, + contrast, + setContrast, + disabled, +}: SidebarAdjustmentsProps) { + const { t } = useTranslation(["tooltip", "keywords"]); + + return ( +
+

+ {t("Adjustments", { ns: "keywords" })} +

+
+ +
+ setBrightness(Number(e.target.value))} + className="flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + disabled={disabled} + /> + + {brightness}% + +
+
+
+ +
+ setContrast(Number(e.target.value))} + className="flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + disabled={disabled} + /> + + {contrast}% + +
+
+
+ ); +} diff --git a/src/components/edit-window/components/SidebarFFT.tsx b/src/components/edit-window/components/SidebarFFT.tsx new file mode 100644 index 00000000..75c6668d --- /dev/null +++ b/src/components/edit-window/components/SidebarFFT.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils/shadcn"; +import { Edit3, Hand, Waves, Trash2 } from "lucide-react"; +import { ICON } from "@/lib/utils/const"; +import { UseFftWorkspaceReturn } from "../fft/useFftWorkspace"; + +interface SidebarFFTProps { + isFftActive: boolean; + setIsFftActive: (active: boolean) => void; + fft: UseFftWorkspaceReturn; +} + +export function SidebarFFT({ + isFftActive, + setIsFftActive, + fft, +}: SidebarFFTProps) { + const { t } = useTranslation(["tooltip", "keywords"]); + + return ( +
+

FFT

+
+ + + {isFftActive && fft.status === "loading" && ( + + {t("Loading...", { ns: "keywords" })} + + )} + {isFftActive && fft.status === "processing" && ( + + {t("Processing...", { ns: "keywords" })} + + )} + {isFftActive && fft.status === "ready" && ( + <> +
+
+
+ + +
+ +
+ +
+ +
+ + fft.setBrushSize( + Number(e.target.value) + ) + } + className="flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + /> + + {fft.brushSize}px + +
+
+ +
+ + +
+
+
+ + +
+ + )} +
+
+ ); +} diff --git a/src/components/edit-window/components/SidebarTools.tsx b/src/components/edit-window/components/SidebarTools.tsx new file mode 100644 index 00000000..548384ff --- /dev/null +++ b/src/components/edit-window/components/SidebarTools.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { Save } from "lucide-react"; +import { ICON } from "@/lib/utils/const"; + +interface SidebarToolsProps { + imageName: string | null; + imageSize: { w: number; h: number } | null; + onSave: () => void; + disabled: boolean; +} + +export function SidebarTools({ + imageName, + imageSize, + onSave, + disabled, +}: SidebarToolsProps) { + const { t } = useTranslation(["tooltip", "keywords"]); + + return ( + <> + {imageName && ( +
+

+ Info +

+

+ {imageName} +

+ {imageSize && ( +

+ {imageSize.w} × {imageSize.h} px +

+ )} +
+ )} + +
+ +
+

+ {t("Tools", { ns: "keywords" })} +

+ +
+ + ); +} diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index 0ab36db3..4a1ef88a 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -2,446 +2,110 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { WindowControls } from "@/components/menu/window-controls"; import { Menubar } from "@/components/ui/menubar"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils/shadcn"; import { ICON } from "@/lib/utils/const"; -import { Edit, Save } from "lucide-react"; -import { listen, emit } from "@tauri-apps/api/event"; -import { readFile, writeFile, exists } from "@tauri-apps/plugin-fs"; -import { basename, extname, join, dirname } from "@tauri-apps/api/path"; -import { toast } from "sonner"; +import { Edit } from "lucide-react"; import { useSettingsSync } from "@/lib/hooks/useSettingsSync"; -import ImageDpiControls from "@/components/edit-window/dpi/image-dpi-controls"; -import ImageFftControls from "@/components/edit-window/fft/image-fft-controls"; +import ImageDpiControls from "./dpi/image-dpi-controls"; +import ImagePanes from "./fft/ImagePanes"; +import { useFftWorkspace } from "./fft/useFftWorkspace"; + +import { useImagePanZoom } from "./hooks/useImagePanZoom"; +import { useImageIO } from "./hooks/useImageIO"; +import { useSyncedElement } from "./hooks/useElementSync"; + +import { SidebarAdjustments } from "./components/SidebarAdjustments"; +import { SidebarTools } from "./components/SidebarTools"; +import { SidebarFFT } from "./components/SidebarFFT"; + +interface EditWindowContentProps { + imageRef?: React.RefObject; + spectrumCanvasRef?: React.RefObject; + previewCanvasRef?: React.RefObject; + onFftApply?: (dataUrl: string) => void; +} -export function EditWindow() { +function EditWindowContent({ + imageRef: providedImageRef, + spectrumCanvasRef: providedSpectrumCanvasRef, + previewCanvasRef: providedPreviewCanvasRef, + onFftApply, +}: EditWindowContentProps) { const { t } = useTranslation(["tooltip", "keywords"]); useSettingsSync(); - const [imagePath, setImagePath] = useState(null); - const [imageUrl, setImageUrl] = useState(null); - const [imageName, setImageName] = useState(null); - const [imageSize, setImageSize] = useState<{ w: number; h: number } | null>( - null - ); - const [error, setError] = useState(null); const [brightness, setBrightness] = useState(100); const [contrast, setContrast] = useState(100); - const [zoom, setZoom] = useState(1); - const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState<{ x: number; y: number }>({ - x: 0, - y: 0, - }); + const [isFftActive, setIsFftActive] = useState(false); - const imageRef = useRef(null); + const internalImageRef = useRef(null); const containerRef = useRef(null); - const canvasRef = useRef(null); - const fftCanvasRef = useRef(null); - - const TRANSFORM_ORIGIN = "center center"; - - const findUniqueFilePath = async ( - directory: string, - baseName: string, - timestamp: string, - extension: string, - initialPath: string - ): Promise => { - let fileExists = false; - try { - fileExists = await exists(initialPath); - } catch { - return initialPath; - } - - if (!fileExists) { - return initialPath; - } - - const maxAttempts = 100; - const pathsToCheck: Promise<{ path: string; exists: boolean }>[] = []; - - for (let i = 1; i <= maxAttempts; i += 1) { - const numberedFilename = `${baseName}_edited_${timestamp}_${i}${extension}`; - const numberedPathPromise = join(directory, numberedFilename); - pathsToCheck.push( - numberedPathPromise.then(path => - exists(path) - .then(exists => ({ path, exists })) - .catch(() => ({ path, exists: false })) - ) - ); - } - - const results = await Promise.all(pathsToCheck); - const firstAvailable = results.find(result => !result.exists); - - if (firstAvailable) { - return firstAvailable.path; - } - - return results[results.length - 1]?.path ?? initialPath; - }; - - const processImageWithFilters = async ( - imgRef: React.RefObject, - brightnessValue: number, - contrastValue: number - ): Promise => { - if (!imgRef.current) throw new Error("Image not loaded"); - const img = imgRef.current; - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) { - throw new Error("Failed to get canvas context"); - } - - canvas.width = img.naturalWidth || img.width; - canvas.height = img.naturalHeight || img.height; - - if (brightnessValue !== 100 || contrastValue !== 100) { - ctx.filter = `brightness(${brightnessValue / 100}) contrast(${contrastValue / 100})`; - } - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - ctx.filter = "none"; - - const editedBlob = await new Promise((resolve, reject) => { - canvas.toBlob( - blob => { - if (blob) { - resolve(blob); - } else { - reject(new Error("Failed to convert canvas to blob")); - } - }, - "image/png", - 1.0 - ); - }); - - const arrayBuffer = await editedBlob.arrayBuffer(); - return new Uint8Array(arrayBuffer); - }; - - const generateFilename = async ( - imagePath: string - ): Promise<{ - nameWithoutExt: string; - extWithDot: string; - timestamp: string; - }> => { - const originalFilename = await basename(imagePath); - const extension = await extname(imagePath); - - const extWithDot = extension - ? extension.startsWith(".") - ? extension - : `.${extension}` - : ".png"; - - const lastDotIndex = originalFilename.lastIndexOf("."); - const nameWithoutExt = - lastDotIndex > 0 - ? originalFilename.slice(0, lastDotIndex) - : originalFilename; - - const timestamp = new Date() - .toISOString() - .replace(/[:.]/g, "-") - .slice(0, -5); - - return { nameWithoutExt, extWithDot, timestamp }; - }; - - const loadImage = async (path: string) => { - try { - setError(null); - setImageUrl(null); - const imageBytes = await readFile(path); - const blob = new Blob([imageBytes]); - const url = URL.createObjectURL(blob); - setImageUrl(url); - setImageName(await basename(path)); - setZoom(1); - setPan({ x: 0, y: 0 }); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : "Failed to load image"; - setError(`${errorMessage} (Path: ${path})`); - setImageUrl(null); - } - }; - - const handleWheel = (e: React.WheelEvent) => { - if (!imageUrl || !containerRef.current || !imageRef.current) return; - - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const newZoom = Math.max(0.1, Math.min(10, zoom * delta)); - - const containerRect = containerRef.current.getBoundingClientRect(); - - const containerCenterX = containerRect.width / 2; - const containerCenterY = containerRect.height / 2; - const mouseX = e.clientX - containerRect.left; - const mouseY = e.clientY - containerRect.top; - - const imageX = (mouseX - containerCenterX - pan.x) / zoom; - const imageY = (mouseY - containerCenterY - pan.y) / zoom; - - const newPanX = mouseX - containerCenterX - imageX * newZoom; - const newPanY = mouseY - containerCenterY - imageY * newZoom; - - setZoom(newZoom); - setPan({ x: newPanX, y: newPanY }); - }; - - const handleMouseDown = (e: React.MouseEvent) => { - if (e.button !== 0) return; - setIsDragging(true); - setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); - }; - - const handleMouseMove = (e: React.MouseEvent) => { - if (!isDragging) return; - setPan({ - x: e.clientX - dragStart.x, - y: e.clientY - dragStart.y, - }); - }; - - const handleMouseUp = () => { - setIsDragging(false); - }; - - /** Forward wheel events from the FFT overlay to zoom the image */ - const fftHandleWheel = useCallback((e: WheelEvent) => { - if (!containerRef.current || !imageRef.current) return; - const delta = e.deltaY > 0 ? 0.9 : 1.1; - setZoom(prev => { - const newZoom = Math.max(0.1, Math.min(10, prev * delta)); - const containerRect = containerRef.current!.getBoundingClientRect(); - const cx = containerRect.width / 2; - const cy = containerRect.height / 2; - const mx = e.clientX - containerRect.left; - const my = e.clientY - containerRect.top; - setPan(p => { - const imgX = (mx - cx - p.x) / prev; - const imgY = (my - cy - p.y) / prev; - return { - x: mx - cx - imgX * newZoom, - y: my - cy - imgY * newZoom, - }; - }); - return newZoom; - }); - }, []); - - /** Forward middle-button drag from FFT overlay to pan the image */ - const fftHandleMiddleDrag = useCallback((dx: number, dy: number) => { - setPan(prev => ({ x: prev.x + dx, y: prev.y + dy })); - }, []); - - const handleDoubleClick = () => { - setZoom(1); - setPan({ x: 0, y: 0 }); - }; - - const resetZoom = () => { - setZoom(1); - setPan({ x: 0, y: 0 }); - }; - - useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const pathFromUrl = urlParams.get("imagePath"); - - if (pathFromUrl) { - const decodedPath = decodeURIComponent(pathFromUrl); - const normalizedPath = decodedPath.replace(/\//g, "\\"); - setImagePath(normalizedPath); - loadImage(normalizedPath); - } - - const setupListener = async () => { - return listen("image-path-changed", event => { - setImagePath(event.payload); - loadImage(event.payload); - }); - }; - - let unlistenPromise: Promise<() => void> | null = null; - setupListener().then(unlisten => { - unlistenPromise = Promise.resolve(unlisten); - }); - - return () => { - if (unlistenPromise) { - unlistenPromise.then(fn => fn()); - } - }; - }, []); - - useEffect(() => { - return () => { - if (imageUrl) { - URL.revokeObjectURL(imageUrl); - } - }; - }, [imageUrl]); - - useEffect(() => { - const img = imageRef.current; - if (!img) return undefined; - const updateSize = () => { - setImageSize({ w: img.naturalWidth, h: img.naturalHeight }); - }; - if (img.complete && img.naturalWidth) updateSize(); - img.addEventListener("load", updateSize); - return () => img.removeEventListener("load", updateSize); - }, [imageUrl]); - - function syncCanvasToImage(img: HTMLImageElement, cvs: HTMLCanvasElement) { - if (!img || !cvs) return; - - const width = img.naturalWidth; - const height = img.naturalHeight; - - Object.assign(cvs, { width, height }); - Object.assign(cvs.style, { - width: `${img.width}px`, - height: `${img.height}px`, - position: "absolute", - zIndex: "10", - }); - - const ctx = cvs.getContext("2d")!; - ctx.setTransform(1, 0, 0, 1, 0, 0); - } - - useEffect(() => { - const img = imageRef.current; - const canvas = canvasRef.current; - if (!img || !canvas) return undefined; - - const sync = () => { - requestAnimationFrame(() => syncCanvasToImage(img, canvas)); - }; - - const resizeObserver = new ResizeObserver(sync); - resizeObserver.observe(img); - - if (img.complete) sync(); - img.addEventListener("load", sync); - - return () => { - resizeObserver.disconnect(); - img.removeEventListener("load", sync); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUrl]); - - /* Sync FFT canvas CSS position to the image (without clearing internal dims) */ + const internalSpectrumCanvasRef = useRef(null); + const internalPreviewCanvasRef = useRef(null); + const fftContainerRef = useRef(null); + const imageRef = providedImageRef ?? internalImageRef; + const canvasRef = providedSpectrumCanvasRef ?? internalSpectrumCanvasRef; + const fftCanvasRef = providedPreviewCanvasRef ?? internalPreviewCanvasRef; + const left = useImagePanZoom(containerRef, imageRef, true); + const right = useImagePanZoom(fftContainerRef, fftCanvasRef, isFftActive); + + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { - const img = imageRef.current; - const fftCanvas = fftCanvasRef.current; - if (!img || !fftCanvas) return undefined; - - const syncFft = () => { - requestAnimationFrame(() => { - if (!fftCanvas || !img) return; - Object.assign(fftCanvas.style, { - width: `${img.width}px`, - height: `${img.height}px`, - position: "absolute", - zIndex: "11", - }); - }); - }; - - const resizeObserver = new ResizeObserver(syncFft); - resizeObserver.observe(img); - - if (img.complete) syncFft(); - img.addEventListener("load", syncFft); - - return () => { - resizeObserver.disconnect(); - img.removeEventListener("load", syncFft); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [imageUrl]); - - const saveEditedImage = async () => { - if (!imageUrl || !imagePath) { - return; - } - - try { - const uint8Array = await processImageWithFilters( - imageRef, - brightness, - contrast - ); - - const { nameWithoutExt, extWithDot, timestamp } = - await generateFilename(imagePath); - const newFilename = `${nameWithoutExt}_edited_${timestamp}${extWithDot}`; - - const imageDir = await dirname(imagePath); - const newImagePath = await join(imageDir, newFilename); - - const finalPath = await findUniqueFilePath( - imageDir, - nameWithoutExt, - timestamp, - extWithDot, - newImagePath - ); - - await writeFile(finalPath, uint8Array); - - const fileWasWritten = await exists(finalPath); - if (!fileWasWritten) { - throw new Error(`File was not created at path: ${finalPath}`); - } + left.reset(); + right.reset(); + }, [isFftActive]); + + const { + imagePath, + imageUrl, + setImageUrl, + imageName, + imageSize, + error, + saveEditedImage, + } = useImageIO(imageRef, t, () => { + left.reset(); + right.reset(); + }); - await emit("image-reload-requested", { - originalPath: imagePath, - newPath: finalPath, - }); + const handleFftApply = useCallback( + (dataUrl: string) => { + setImageUrl(dataUrl); + onFftApply?.(dataUrl); + left.reset(); + right.reset(); + }, + [onFftApply, setImageUrl, left, right] + ); - setImagePath(finalPath); - setImageName(await basename(finalPath)); - const blob = new Blob([uint8Array], { type: "image/png" }); - const url = URL.createObjectURL(blob); - setImageUrl(url); + const fft = useFftWorkspace({ + imageRef, + spectrumCanvasRef: canvasRef, + previewCanvasRef: fftCanvasRef, + isActive: isFftActive, + onToggleActive: setIsFftActive, + onApply: handleFftApply, + onWheel: left.handleWheel, + onMiddleDrag: left.handleMiddleDrag, + }); - toast.success(t("Image saved successfully", { ns: "tooltip" })); - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : String(err); - toast.error( - t("Failed to save image: {{error}}", { - ns: "tooltip", - error: errorMessage, - }) - ); - } - }; + useSyncedElement(imageRef, imageRef, containerRef, [imageUrl]); + useSyncedElement(imageRef, canvasRef, containerRef, [imageUrl]); + useSyncedElement( + imageRef, + fftCanvasRef, + fftContainerRef, + [imageUrl, isFftActive], + { zIndex: "11" } + ); return (
-
+
) : imageUrl ? ( -
- -
- )} -
+ + right.handleMouseDown(e, [0, 1]) + } + onRightMouseMove={right.handleMouseMove} + onRightMouseUp={right.handleMouseUp} + onRightDoubleClick={right.reset} + onResetRightZoom={right.reset} + /> ) : (
@@ -560,100 +189,24 @@ export function EditWindow() {
)}
-
- {imageName && ( -
-

- Info -

-

- {imageName} -

- {imageSize && ( -

- {imageSize.w} × {imageSize.h} px -

- )} -
- )} -
- -
-

- {t("Tools", { ns: "keywords" })} -

- -
+
+ saveEditedImage(brightness, contrast)} + disabled={!imageUrl || !imagePath} + />
-
-

- {t("Adjustments", { ns: "keywords" })} -

-
- -
- - setBrightness(Number(e.target.value)) - } - className="flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" - disabled={!imageUrl} - /> - - {brightness}% - -
-
-
- -
- - setContrast(Number(e.target.value)) - } - className="flex-1 h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" - disabled={!imageUrl} - /> - - {contrast}% - -
-
-
+
@@ -669,25 +222,38 @@ export function EditWindow() {
-
-

- FFT -

- { - if (imageUrl && imageUrl.startsWith("blob:")) { - URL.revokeObjectURL(imageUrl); - } - setImageUrl(dataUrl); - }} - onWheel={fftHandleWheel} - onMiddleDrag={fftHandleMiddleDrag} - /> -
+
); } + +export function EditWindow() { + return ; +} + +export function EditWindowWithProps({ + imageRef, + spectrumCanvasRef, + previewCanvasRef, + onApply, +}: { + imageRef: React.RefObject; + spectrumCanvasRef: React.RefObject; + previewCanvasRef: React.RefObject; + onApply: (dataUrl: string) => void; +}) { + return ( + + ); +} diff --git a/src/components/edit-window/fft/ImagePanes.tsx b/src/components/edit-window/fft/ImagePanes.tsx new file mode 100644 index 00000000..21b60760 --- /dev/null +++ b/src/components/edit-window/fft/ImagePanes.tsx @@ -0,0 +1,241 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +import React, { RefObject } from "react"; +import { cn } from "@/lib/utils/shadcn"; +import { Button } from "@/components/ui/button"; +import { useTranslation } from "react-i18next"; +import type { FftStatus } from "./fftTypes"; + +interface ImagePanesProps { + // shared + imageUrl: string; + imagePath: string | null; + isFftActive: boolean; + fftStatus: FftStatus; + + // left pane — original image + containerRef: RefObject; + imageRef: RefObject; + spectrumCanvasRef: RefObject; + brightness: number; + contrast: number; + zoom: number; + pan: { x: number; y: number }; + isDragging: boolean; + onWheel: (e: React.WheelEvent) => void; + onMouseDown: (e: React.MouseEvent) => void; + onMouseMove: (e: React.MouseEvent) => void; + onMouseUp: () => void; + onDoubleClick: () => void; + onResetZoom: () => void; + + // right pane — FFT preview + fftContainerRef: RefObject; + previewCanvasRef: RefObject; + rightPanZoom: number; + rightPan: { x: number; y: number }; + isRightDragging: boolean; + onRightWheel: (e: React.WheelEvent) => void; + onRightMouseDown: (e: React.MouseEvent) => void; + onRightMouseMove: (e: React.MouseEvent) => void; + onRightMouseUp: () => void; + onRightDoubleClick: () => void; + onResetRightZoom: () => void; +} + +const TRANSFORM_ORIGIN = "center center"; + +function ImagePanes({ + imageUrl, + imagePath, + isFftActive, + fftStatus, + containerRef, + imageRef, + spectrumCanvasRef, + brightness, + contrast, + zoom, + pan, + isDragging, + onWheel, + onMouseDown, + onMouseMove, + onMouseUp, + onDoubleClick, + onResetZoom, + fftContainerRef, + previewCanvasRef, + rightPanZoom, + rightPan, + isRightDragging, + onRightWheel, + onRightMouseDown, + onRightMouseMove, + onRightMouseUp, + onRightDoubleClick, + onResetRightZoom, +}: ImagePanesProps) { + const { t } = useTranslation(["tooltip", "keywords"]); + const isFftReady = isFftActive && fftStatus === "ready"; + const isFftLoading = + isFftActive && (fftStatus === "loading" || fftStatus === "processing"); + + return ( +
+ {/* ── Left pane: original image ── */} +
+
+ +
+ )} +
+ + {/* ── Right pane: FFT preview ── */} +
+ {isFftActive && ( + <> + +
+ )} + + )} +
+
+ ); +} +export default ImagePanes; diff --git a/src/components/edit-window/fft/fftCanvasUtils.ts b/src/components/edit-window/fft/fftCanvasUtils.ts new file mode 100644 index 00000000..ae960d4e --- /dev/null +++ b/src/components/edit-window/fft/fftCanvasUtils.ts @@ -0,0 +1,39 @@ +export function redrawFftOverlay( + spectrumCanvas: HTMLCanvasElement, + specCanvas: HTMLCanvasElement, + maskCanvas: HTMLCanvasElement | null +) { + const ctx = spectrumCanvas.getContext("2d"); + if (!ctx) return; + + ctx.clearRect(0, 0, spectrumCanvas.width, spectrumCanvas.height); + ctx.globalAlpha = 1.0; + ctx.drawImage( + specCanvas, + 0, + 0, + spectrumCanvas.width, + spectrumCanvas.height + ); + + if (maskCanvas) { + ctx.drawImage( + maskCanvas, + 0, + 0, + spectrumCanvas.width, + spectrumCanvas.height + ); + } +} + +export function getCanvasCoords( + e: MouseEvent, + canvas: HTMLCanvasElement +): { cx: number; cy: number } { + const rect = canvas.getBoundingClientRect(); + return { + cx: (e.clientX - rect.left) * (canvas.width / rect.width), + cy: (e.clientY - rect.top) * (canvas.height / rect.height), + }; +} diff --git a/src/components/edit-window/fft/fftTypes.ts b/src/components/edit-window/fft/fftTypes.ts new file mode 100644 index 00000000..63aab16a --- /dev/null +++ b/src/components/edit-window/fft/fftTypes.ts @@ -0,0 +1,3 @@ +export type FftStatus = "idle" | "loading" | "ready" | "processing"; +export type InteractionMode = "draw" | "erase" | "pan"; +export type BrushShape = "circle" | "oval"; diff --git a/src/components/edit-window/fft/image-fft-controls.tsx b/src/components/edit-window/fft/image-fft-controls.tsx deleted file mode 100644 index ad5215aa..00000000 --- a/src/components/edit-window/fft/image-fft-controls.tsx +++ /dev/null @@ -1,485 +0,0 @@ -import React, { - RefObject, - useCallback, - useEffect, - useRef, - useState, -} from "react"; -import { Waves, Trash2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { ICON } from "@/lib/utils/const"; -import { ImageFFT, type FFTResult } from "@/lib/fftProcessor"; -import { useTranslation } from "react-i18next"; - -type FftStatus = "idle" | "loading" | "ready" | "processing"; -type FftViewMode = "edit" | "preview"; - -interface ImageFftControlsProps { - imageRef: RefObject; - canvasRef: RefObject; - onApply: (dataUrl: string) => void; - onWheel?: (e: WheelEvent) => void; - onMiddleDrag?: (dx: number, dy: number) => void; -} - -export default function ImageFftControls({ - imageRef, - canvasRef, - onApply, - onWheel, - onMiddleDrag, -}: ImageFftControlsProps) { - const { t } = useTranslation(["keywords", "tooltip"]); - - const [active, setActive] = useState(false); - const [brushSize, setBrushSize] = useState(30); - const [spectrumOpacity, setSpectrumOpacity] = useState(75); - const [viewMode, setViewMode] = useState("edit"); - const [status, setStatus] = useState("idle"); - - const processorRef = useRef(null); - const fftResultRef = useRef(null); - const maskCanvasRef = useRef(null); - const specCanvasRef = useRef(null); - const isDrawingRef = useRef(false); - const isPanningRef = useRef(false); - const lastPanPosRef = useRef({ x: 0, y: 0 }); - const brushSizeRef = useRef(brushSize); - const spectrumOpacityRef = useRef(spectrumOpacity); - const originalDimsRef = useRef({ w: 0, h: 0 }); - - useEffect(() => { - brushSizeRef.current = brushSize; - }, [brushSize]); - - useEffect(() => { - spectrumOpacityRef.current = spectrumOpacity; - }, [spectrumOpacity]); - - /** Redraw the overlay canvas: spectrum + mask */ - const redrawOverlay = useCallback(() => { - const canvas = canvasRef.current; - const specCvs = specCanvasRef.current; - if (!canvas || !specCvs) return; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.globalAlpha = spectrumOpacityRef.current / 100; - ctx.drawImage(specCvs, 0, 0, canvas.width, canvas.height); - ctx.globalAlpha = 1.0; - - const maskCvs = maskCanvasRef.current; - if (maskCvs) { - ctx.drawImage(maskCvs, 0, 0, canvas.width, canvas.height); - } - }, [canvasRef]); - - /** Compute FFT when activating; clean up when deactivating */ - useEffect(() => { - if (!active) { - const canvas = canvasRef.current; - if (canvas) { - canvas.style.pointerEvents = "none"; - const ctx = canvas.getContext("2d"); - ctx?.clearRect(0, 0, canvas.width, canvas.height); - } - processorRef.current = null; - fftResultRef.current = null; - maskCanvasRef.current = null; - specCanvasRef.current = null; - setStatus("idle"); - setViewMode("edit"); - return undefined; - } - - const img = imageRef.current; - const canvas = canvasRef.current; - if (!img || !canvas) return undefined; - - setStatus("loading"); - setViewMode("edit"); - - const timer = setTimeout(() => { - try { - const w = img.naturalWidth; - const h = img.naturalHeight; - originalDimsRef.current = { w, h }; - - const tmpCvs = document.createElement("canvas"); - tmpCvs.width = w; - tmpCvs.height = h; - const tmpCtx = tmpCvs.getContext("2d", { - willReadFrequently: true, - }); - if (!tmpCtx) throw new Error("Canvas context unavailable"); - tmpCtx.drawImage(img, 0, 0); - const imageData = tmpCtx.getImageData(0, 0, w, h); - - const processor = new ImageFFT(w, h); - const result = processor.forward(imageData); - - processorRef.current = processor; - fftResultRef.current = result; - - // Offscreen mask at FFT dimensions - const maskCvs = document.createElement("canvas"); - maskCvs.width = result.width; - maskCvs.height = result.height; - maskCanvasRef.current = maskCvs; - - // Offscreen spectrum at FFT dimensions - const specCvs = document.createElement("canvas"); - specCvs.width = result.width; - specCvs.height = result.height; - const specCtx = specCvs.getContext("2d"); - if (specCtx) { - const specImg = new ImageData( - new Uint8ClampedArray(result.spectrum.buffer), - result.width, - result.height - ); - specCtx.putImageData(specImg, 0, 0); - } - specCanvasRef.current = specCvs; - - // Set overlay canvas internal size to image dimensions - /* eslint-disable no-param-reassign */ - canvas.width = w; - canvas.height = h; - /* eslint-enable no-param-reassign */ - - redrawOverlay(); - // eslint-disable-next-line no-param-reassign - canvas.style.pointerEvents = "auto"; - setStatus("ready"); - } catch { - setStatus("idle"); - setActive(false); - } - }, 100); - - return () => clearTimeout(timer); - }, [active, imageRef, canvasRef, redrawOverlay]); - - /** Attach mouse event handlers for painting on the overlay */ - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas || !active || status !== "ready" || viewMode !== "edit") - return undefined; - - const fftResult = fftResultRef.current; - const maskCvs = maskCanvasRef.current; - if (!fftResult || !maskCvs) return undefined; - - const getCoords = (e: MouseEvent) => { - const rect = canvas.getBoundingClientRect(); - return { - cx: (e.clientX - rect.left) * (canvas.width / rect.width), - cy: (e.clientY - rect.top) * (canvas.height / rect.height), - }; - }; - - const paintAt = (cx: number, cy: number) => { - const maskCtx = maskCvs.getContext("2d"); - if (!maskCtx) return; - const scaleX = fftResult.width / canvas.width; - const scaleY = fftResult.height / canvas.height; - maskCtx.globalCompositeOperation = "source-over"; - maskCtx.fillStyle = "#c00000"; - maskCtx.beginPath(); - maskCtx.arc( - cx * scaleX, - cy * scaleY, - brushSizeRef.current * Math.max(scaleX, scaleY), - 0, - Math.PI * 2 - ); - maskCtx.fill(); - redrawOverlay(); - }; - - const onDown = (e: MouseEvent) => { - if (e.button !== 0) return; - isDrawingRef.current = true; - const { cx, cy } = getCoords(e); - paintAt(cx, cy); - }; - - const onMove = (e: MouseEvent) => { - if (!isDrawingRef.current) return; - const { cx, cy } = getCoords(e); - paintAt(cx, cy); - }; - - const onUp = () => { - isDrawingRef.current = false; - }; - - /* Forward wheel events (zoom) and middle-button drag (pan) to parent */ - const onWheelForward = (e: WheelEvent) => { - e.preventDefault(); - onWheel?.(e); - }; - - const onMiddleDown = (e: MouseEvent) => { - if (e.button === 1) { - e.preventDefault(); - isPanningRef.current = true; - lastPanPosRef.current = { x: e.clientX, y: e.clientY }; - } - }; - - const onMiddleMove = (e: MouseEvent) => { - if (!isPanningRef.current) return; - const dx = e.clientX - lastPanPosRef.current.x; - const dy = e.clientY - lastPanPosRef.current.y; - lastPanPosRef.current = { x: e.clientX, y: e.clientY }; - onMiddleDrag?.(dx, dy); - }; - - const onMiddleUp = (e: MouseEvent) => { - if (e.button === 1) isPanningRef.current = false; - }; - - canvas.addEventListener("mousedown", onDown); - canvas.addEventListener("mousedown", onMiddleDown); - canvas.addEventListener("mousemove", onMove); - canvas.addEventListener("mousemove", onMiddleMove); - canvas.addEventListener("mouseup", onUp); - canvas.addEventListener("mouseup", onMiddleUp); - canvas.addEventListener("mouseleave", onUp); - canvas.addEventListener("mouseleave", onMiddleUp); - canvas.addEventListener("wheel", onWheelForward, { passive: false }); - - return () => { - canvas.removeEventListener("mousedown", onDown); - canvas.removeEventListener("mousedown", onMiddleDown); - canvas.removeEventListener("mousemove", onMove); - canvas.removeEventListener("mousemove", onMiddleMove); - canvas.removeEventListener("mouseup", onUp); - canvas.removeEventListener("mouseup", onMiddleUp); - canvas.removeEventListener("mouseleave", onUp); - canvas.removeEventListener("mouseleave", onMiddleUp); - canvas.removeEventListener("wheel", onWheelForward); - }; - }, [ - active, - status, - viewMode, - canvasRef, - redrawOverlay, - onWheel, - onMiddleDrag, - ]); - - /** Toggle between spectrum editor and filtered preview */ - const togglePreview = useCallback(() => { - const canvas = canvasRef.current; - const processor = processorRef.current; - const fftResult = fftResultRef.current; - const maskCvs = maskCanvasRef.current; - if (!canvas || !processor || !fftResult || !maskCvs) return; - - if (viewMode === "edit") { - setStatus("processing"); - setTimeout(() => { - const maskCtx = maskCvs.getContext("2d"); - if (!maskCtx) return; - const maskImgData = maskCtx.getImageData( - 0, - 0, - fftResult.width, - fftResult.height - ); - const filteredData = processor.applyMask( - fftResult.complexData, - maskImgData.data - ); - const resultImage = processor.inverse( - filteredData, - canvas.width, - canvas.height - ); - - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.putImageData(resultImage, 0, 0); - } - // eslint-disable-next-line no-param-reassign - canvas.style.pointerEvents = "none"; - setViewMode("preview"); - setStatus("ready"); - }, 50); - } else { - redrawOverlay(); - // eslint-disable-next-line no-param-reassign - canvas.style.pointerEvents = "auto"; - setViewMode("edit"); - } - }, [viewMode, canvasRef, redrawOverlay]); - - /** Apply the FFT filter permanently to the image */ - const applyFilter = useCallback(() => { - const processor = processorRef.current; - const fftResult = fftResultRef.current; - const maskCvs = maskCanvasRef.current; - if (!processor || !fftResult || !maskCvs) return; - - setStatus("processing"); - setTimeout(() => { - const maskCtx = maskCvs.getContext("2d"); - if (!maskCtx) return; - const maskImgData = maskCtx.getImageData( - 0, - 0, - fftResult.width, - fftResult.height - ); - const filteredData = processor.applyMask( - fftResult.complexData, - maskImgData.data - ); - const { w, h } = originalDimsRef.current; - const resultImage = processor.inverse(filteredData, w, h); - - const outCvs = document.createElement("canvas"); - outCvs.width = w; - outCvs.height = h; - outCvs.getContext("2d")?.putImageData(resultImage, 0, 0); - const dataUrl = outCvs.toDataURL("image/png"); - - onApply(dataUrl); - setActive(false); - }, 50); - }, [onApply]); - - /** Clear the painting mask */ - const clearMask = useCallback(() => { - const maskCvs = maskCanvasRef.current; - if (maskCvs) { - const ctx = maskCvs.getContext("2d"); - ctx?.clearRect(0, 0, maskCvs.width, maskCvs.height); - } - if (viewMode === "edit") { - redrawOverlay(); - } - }, [viewMode, redrawOverlay]); - - return ( -
- - - {active && status === "loading" && ( - - {t("Loading...", { ns: "keywords" })} - - )} - - {active && status === "processing" && ( - - {t("Processing...", { ns: "keywords" })} - - )} - - {active && status === "ready" && ( - <> - {viewMode === "edit" && ( - <> -

- {t( - "Paint over bright spots to filter them out", - { ns: "tooltip" } - )} -

-
- - - setBrushSize( - parseInt(e.target.value, 10) - ) - } - className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-secondary accent-primary" - /> -
-
- - { - const v = parseInt(e.target.value, 10); - setSpectrumOpacity(v); - spectrumOpacityRef.current = v; - redrawOverlay(); - }} - className="h-2 w-full cursor-pointer appearance-none rounded-lg bg-secondary accent-primary" - /> -
- - )} - -
- - - {viewMode === "edit" && ( - - )} -
- - )} -
- ); -} diff --git a/src/components/edit-window/fft/useFftInit.ts b/src/components/edit-window/fft/useFftInit.ts new file mode 100644 index 00000000..8b3be203 --- /dev/null +++ b/src/components/edit-window/fft/useFftInit.ts @@ -0,0 +1,140 @@ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-console */ +import React, { RefObject, useEffect } from "react"; +import { ImageFFT, type FFTResult } from "@/lib/fftProcessor"; +import { FftStatus } from "./fftTypes"; + +// this file contains fft lifecycle +// init processor,build offscreen canvas + +export interface FftRefs { + processorRef: React.MutableRefObject; + fftResultRef: React.MutableRefObject; + maskCanvasRef: React.MutableRefObject; + specCanvasRef: React.MutableRefObject; + originalDimsRef: React.MutableRefObject<{ w: number; h: number }>; +} + +export function useFftInit({ + isActive, + imageRef, + spectrumCanvasRef, + previewCanvasRef, + refs, + onToggleActive, + onReady, + onStatusChange, +}: { + isActive: boolean; + imageRef: RefObject; + spectrumCanvasRef: RefObject; + previewCanvasRef: RefObject; + refs: FftRefs; + onToggleActive: (active: boolean) => void; + onReady: () => void; + onStatusChange: (status: FftStatus) => void; +}) { + useEffect(() => { + if (!isActive) { + spectrumCanvasRef.current + ?.getContext("2d") + ?.clearRect( + 0, + 0, + spectrumCanvasRef.current.width, + spectrumCanvasRef.current.height + ); + if (spectrumCanvasRef.current) + spectrumCanvasRef.current.style.pointerEvents = "none"; + + previewCanvasRef.current + ?.getContext("2d") + ?.clearRect( + 0, + 0, + previewCanvasRef.current.width, + previewCanvasRef.current.height + ); + if (previewCanvasRef.current) + previewCanvasRef.current.style.pointerEvents = "none"; + + refs.processorRef.current = null; + refs.fftResultRef.current = null; + refs.maskCanvasRef.current = null; + refs.specCanvasRef.current = null; + + onStatusChange("idle"); + return undefined; + } + + const img = imageRef.current; + const sc = spectrumCanvasRef.current; + const pc = previewCanvasRef.current; + if (!img || !sc || !pc) return undefined; + + onStatusChange("loading"); + + const timer = setTimeout(() => { + try { + const w = img.naturalWidth; + const h = img.naturalHeight; + refs.originalDimsRef.current = { w, h }; + + const tmpCvs = document.createElement("canvas"); + tmpCvs.width = w; + tmpCvs.height = h; + const tmpCtx = tmpCvs.getContext("2d", { + willReadFrequently: true, + }); + if (!tmpCtx) throw new Error("Canvas context unavailable"); + + tmpCtx.drawImage(img, 0, 0); + const imageData = tmpCtx.getImageData(0, 0, w, h); + + const processor = new ImageFFT(w, h); + const result = processor.forward(imageData); + + refs.processorRef.current = processor; + refs.fftResultRef.current = result; + + const maskCvs = document.createElement("canvas"); + maskCvs.width = result.width; + maskCvs.height = result.height; + refs.maskCanvasRef.current = maskCvs; + + const specCvs = document.createElement("canvas"); + specCvs.width = result.width; + specCvs.height = result.height; + const specCtx = specCvs.getContext("2d"); + if (specCtx) { + specCtx.putImageData( + new ImageData( + new Uint8ClampedArray(result.spectrum), + result.width, + result.height + ), + 0, + 0 + ); + } + refs.specCanvasRef.current = specCvs; + + sc.width = w; + sc.height = h; + sc.style.pointerEvents = "auto"; + pc.width = w; + pc.height = h; + pc.style.pointerEvents = "none"; + + onReady(); + onStatusChange("ready"); + } catch (err) { + console.error("FFT init failed", err); + onStatusChange("idle"); + onToggleActive(false); + } + }, 50); + + return () => clearTimeout(timer); + }, [isActive, imageRef, spectrumCanvasRef, previewCanvasRef]); +} diff --git a/src/components/edit-window/fft/useFftPainter.ts b/src/components/edit-window/fft/useFftPainter.ts new file mode 100644 index 00000000..d76c3dc8 --- /dev/null +++ b/src/components/edit-window/fft/useFftPainter.ts @@ -0,0 +1,226 @@ +import React, { RefObject, useEffect, useRef } from "react"; +import { FFTResult } from "@/lib/fftProcessor"; +import { BrushShape, FftStatus, InteractionMode } from "./fftTypes"; +import { getCanvasCoords } from "./fftCanvasUtils"; + +// this file contains mouse event lifecycle + +export function useFftPainter({ + isActive, + status, + interactionMode, + spectrumCanvasRef, + fftResultRef, + maskCanvasRef, + brushSizeRef, + brushShapeRef, + onRedrawOverlay, + onPreviewUpdate, + onWheel, + onMiddleDrag, +}: { + isActive: boolean; + status: FftStatus; + interactionMode: InteractionMode; + spectrumCanvasRef: RefObject; + fftResultRef: RefObject; + maskCanvasRef: RefObject; + brushSizeRef: React.MutableRefObject; + brushShapeRef: React.MutableRefObject; + onRedrawOverlay: () => void; + onPreviewUpdate: () => void; + onWheel?: (e: WheelEvent) => void; + onMiddleDrag?: (dx: number, dy: number) => void; +}) { + const isPanningRef = useRef(false); + const isDrawingRef = useRef(false); + const lastPanPosRef = useRef({ x: 0, y: 0 }); + const callbacksRef = useRef({ onWheel, onMiddleDrag }); + + useEffect(() => { + callbacksRef.current = { onWheel, onMiddleDrag }; + }, [onWheel, onMiddleDrag]); + + useEffect(() => { + const canvas = spectrumCanvasRef.current; + if (!canvas || !isActive || status !== "ready") return undefined; + + canvas.style.cursor = + interactionMode === "draw" || interactionMode === "erase" + ? "crosshair" + : "grab"; + + const paintAt = (cx: number, cy: number) => { + const fftResult = fftResultRef.current; + const maskCvs = maskCanvasRef.current; + if (!fftResult || !maskCvs) return; + + const ctx = maskCvs.getContext("2d"); + if (!ctx) return; + + const scaleX = fftResult.width / canvas.width; + const scaleY = fftResult.height / canvas.height; + const brushRadius = + (brushSizeRef.current / 2) * Math.max(scaleX, scaleY); + + ctx.globalCompositeOperation = + interactionMode === "erase" ? "destination-out" : "source-over"; + ctx.fillStyle = + interactionMode === "erase" ? "rgba(0,0,0,1)" : "#c00000"; + + const paintEllipseAt = (x: number, y: number) => { + ctx.beginPath(); + if (brushShapeRef.current === "oval") { + ctx.ellipse( + x, + y, + brushRadius, + brushRadius / 2, + 0, + 0, + Math.PI * 2 + ); + } else { + ctx.arc(x, y, brushRadius, 0, Math.PI * 2); + } + ctx.fill(); + }; + + paintEllipseAt(cx * scaleX, cy * scaleY); + // mirror stroke symmetrically (FFT is centro-symmetric) + paintEllipseAt( + maskCvs.width - cx * scaleX, + maskCvs.height - cy * scaleY + ); + + onRedrawOverlay(); + }; + + let tickingMask = false; + let lastMoveEvent: MouseEvent | null = null; + let requiresPreviewUpdate = false; + + const onDown = (e: MouseEvent) => { + if (interactionMode === "draw" || interactionMode === "erase") { + if (e.button !== 0) return; + isDrawingRef.current = true; + const { cx, cy } = getCanvasCoords(e, canvas); + paintAt(cx, cy); + requiresPreviewUpdate = true; + } else if ( + interactionMode === "pan" && + (e.button === 0 || e.button === 1) + ) { + e.preventDefault(); + isPanningRef.current = true; + lastPanPosRef.current = { x: e.clientX, y: e.clientY }; + canvas.style.cursor = "grabbing"; + } + }; + + const onMove = (e: MouseEvent) => { + lastMoveEvent = e; + if (!tickingMask) { + requestAnimationFrame(() => { + if (lastMoveEvent) { + const { cx, cy } = getCanvasCoords( + lastMoveEvent, + canvas + ); + if ( + isDrawingRef.current && + (interactionMode === "draw" || + interactionMode === "erase") + ) { + paintAt(cx, cy); + requiresPreviewUpdate = true; + } else if (isPanningRef.current) { + const dx = + lastMoveEvent.clientX - lastPanPosRef.current.x; + const dy = + lastMoveEvent.clientY - lastPanPosRef.current.y; + lastPanPosRef.current = { + x: lastMoveEvent.clientX, + y: lastMoveEvent.clientY, + }; + callbacksRef.current.onMiddleDrag?.(dx, dy); + } + } + tickingMask = false; + }); + tickingMask = true; + } + }; + + const flushAndUp = (e: MouseEvent) => { + if ( + lastMoveEvent && + tickingMask && + isDrawingRef.current && + (interactionMode === "draw" || interactionMode === "erase") + ) { + const { cx, cy } = getCanvasCoords(lastMoveEvent, canvas); + paintAt(cx, cy); + requiresPreviewUpdate = true; + lastMoveEvent = null; + } + if (isDrawingRef.current && requiresPreviewUpdate) { + e.preventDefault(); + onPreviewUpdate(); + requiresPreviewUpdate = false; + } + isDrawingRef.current = false; + isPanningRef.current = false; + if (interactionMode === "pan") canvas.style.cursor = "grab"; + }; + + const onMiddleDown = (e: MouseEvent) => { + if (e.button !== 1) return; + e.preventDefault(); + isPanningRef.current = true; + lastPanPosRef.current = { x: e.clientX, y: e.clientY }; + if (interactionMode === "draw" || interactionMode === "erase") + canvas.style.cursor = "grabbing"; + }; + + const onMiddleUp = (e: MouseEvent) => { + if (e.button !== 1) return; + isPanningRef.current = false; + if (interactionMode === "draw" || interactionMode === "erase") + canvas.style.cursor = "crosshair"; + }; + + const onLeave = (e: MouseEvent) => { + if (isDrawingRef.current && requiresPreviewUpdate) { + onPreviewUpdate(); + requiresPreviewUpdate = false; + } + flushAndUp(e); + onMiddleUp(e); + onRedrawOverlay(); + }; + + const onWheelForward = (e: WheelEvent) => { + e.preventDefault(); + callbacksRef.current.onWheel?.(e); + }; + + canvas.addEventListener("mousedown", onDown); + canvas.addEventListener("mousedown", onMiddleDown); + canvas.addEventListener("mousemove", onMove); + canvas.addEventListener("mouseup", flushAndUp); + canvas.addEventListener("mouseup", onMiddleUp); + canvas.addEventListener("mouseleave", onLeave); + canvas.addEventListener("wheel", onWheelForward, { passive: false }); + + return () => { + canvas.removeEventListener("mousedown", onDown); + canvas.removeEventListener("mousedown", onMiddleDown); + canvas.removeEventListener("mousemove", onMove); + canvas.removeEventListener("mouseup", flushAndUp); + canvas.removeEventListener("mouseup", onMiddleUp); + canvas.removeEventListener("mouseleave", onLeave); + canvas.removeEventListener("wheel", onWheelForward); + }; + }, [isActive, status, interactionMode, spectrumCanvasRef]); +} diff --git a/src/components/edit-window/fft/useFftWorkspace.ts b/src/components/edit-window/fft/useFftWorkspace.ts new file mode 100644 index 00000000..6ffc1f2e --- /dev/null +++ b/src/components/edit-window/fft/useFftWorkspace.ts @@ -0,0 +1,186 @@ +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { ImageFFT, type FFTResult } from "@/lib/fftProcessor"; +import { useFftInit, type FftRefs } from "./useFftInit"; +import { useFftPainter } from "./useFftPainter"; +import { redrawFftOverlay } from "./fftCanvasUtils"; +import { FftStatus, InteractionMode, BrushShape } from "./fftTypes"; + +export interface UseFftWorkspaceProps { + imageRef: RefObject; + spectrumCanvasRef: RefObject; + previewCanvasRef: RefObject; + isActive: boolean; + onToggleActive: (active: boolean) => void; + onApply: (dataUrl: string) => void; + onWheel?: (e: WheelEvent) => void; + onMiddleDrag?: (dx: number, dy: number) => void; +} + +export interface UseFftWorkspaceReturn { + status: FftStatus; + brushSize: number; + setBrushSize: (size: number) => void; + brushShape: BrushShape; + setBrushShape: (shape: BrushShape) => void; + interactionMode: InteractionMode; + setInteractionMode: (mode: InteractionMode) => void; + applyFilter: () => void; + clearMask: () => void; +} + +export function useFftWorkspace({ + imageRef, + spectrumCanvasRef, + previewCanvasRef, + isActive, + onToggleActive, + onApply, + onWheel, + onMiddleDrag, +}: UseFftWorkspaceProps): UseFftWorkspaceReturn { + const [brushSize, setBrushSize] = useState(7); + const [brushShape, setBrushShape] = useState("circle"); + const [interactionMode, setInteractionMode] = + useState("draw"); + const [status, setStatus] = useState("idle"); + + // Shared mutable refs — owned here, passed by reference to sub-hooks + const processorRef = useRef(null); + const fftResultRef = useRef(null); + const maskCanvasRef = useRef(null); + const specCanvasRef = useRef(null); + const originalDimsRef = useRef({ w: 0, h: 0 }); + + const brushSizeRef = useRef(brushSize); + const brushShapeRef = useRef(brushShape); + brushSizeRef.current = brushSize; + brushShapeRef.current = brushShape; + + const refs: FftRefs = { + processorRef, + fftResultRef, + maskCanvasRef, + specCanvasRef, + originalDimsRef, + }; + + const doRedrawOverlay = useCallback(() => { + const sc = spectrumCanvasRef.current; + const specCvs = specCanvasRef.current; + if (sc && specCvs) redrawFftOverlay(sc, specCvs, maskCanvasRef.current); + }, [spectrumCanvasRef]); + + const updateLivePreview = useCallback(() => { + const processor = processorRef.current; + const fftResult = fftResultRef.current; + const maskCvs = maskCanvasRef.current; + const outCvs = previewCanvasRef.current; + if (!processor || !fftResult || !maskCvs || !outCvs) return; + + const maskCtx = maskCvs.getContext("2d"); + if (!maskCtx) return; + + const maskImgData = maskCtx.getImageData( + 0, + 0, + fftResult.width, + fftResult.height + ); + const filteredData = processor.applyMask( + fftResult.complexData, + maskImgData.data, + fftResult.width, + fftResult.height + ); + + const { w, h } = originalDimsRef.current; + const resultImage = processor.inverse(filteredData, w, h); + + const ctx = outCvs.getContext("2d"); + if (ctx) ctx.putImageData(resultImage, 0, 0); + }, [previewCanvasRef]); + + useFftInit({ + isActive, + imageRef, + spectrumCanvasRef, + previewCanvasRef, + refs, + onToggleActive, + onStatusChange: setStatus, + onReady: () => { + doRedrawOverlay(); + updateLivePreview(); + }, + }); + + useFftPainter({ + isActive, + status, + interactionMode, + spectrumCanvasRef, + fftResultRef, + maskCanvasRef, + brushSizeRef, + brushShapeRef, + onRedrawOverlay: doRedrawOverlay, + onPreviewUpdate: updateLivePreview, + onWheel, + onMiddleDrag, + }); + + useEffect(() => { + if (!isActive || status !== "ready") return undefined; + let rafId = 0; + let nestedRafId = 0; + + rafId = requestAnimationFrame(() => { + doRedrawOverlay(); + updateLivePreview(); + + nestedRafId = requestAnimationFrame(() => { + doRedrawOverlay(); + updateLivePreview(); + }); + }); + + return () => { + cancelAnimationFrame(rafId); + cancelAnimationFrame(nestedRafId); + }; + }, [isActive, status, doRedrawOverlay, updateLivePreview]); + + const applyFilter = useCallback(() => { + const outCvs = previewCanvasRef.current; + if (!outCvs) return; + setStatus("processing"); + setTimeout(() => { + const dataUrl = outCvs.toDataURL("image/png"); + onApply(dataUrl); + onToggleActive(false); + }, 50); + }, [onApply, onToggleActive, previewCanvasRef]); + + const clearMask = useCallback(() => { + const maskCvs = maskCanvasRef.current; + if (maskCvs) { + maskCvs + .getContext("2d") + ?.clearRect(0, 0, maskCvs.width, maskCvs.height); + } + doRedrawOverlay(); + updateLivePreview(); + }, [doRedrawOverlay, updateLivePreview]); + + return { + status, + brushSize, + setBrushSize, + brushShape, + setBrushShape, + interactionMode, + setInteractionMode, + applyFilter, + clearMask, + }; +} diff --git a/src/components/edit-window/hooks/useElementSync.ts b/src/components/edit-window/hooks/useElementSync.ts new file mode 100644 index 00000000..92378c03 --- /dev/null +++ b/src/components/edit-window/hooks/useElementSync.ts @@ -0,0 +1,90 @@ +import React, { useCallback, useEffect } from "react"; + +export function useElementSync() { + const syncContainedElement = useCallback( + ( + element: HTMLElement, + container: HTMLElement, + naturalWidth: number, + naturalHeight: number, + extraStyles: Partial = {} + ) => { + if (!naturalWidth || !naturalHeight) return; + + const { clientWidth, clientHeight } = container; + if (!clientWidth || !clientHeight) return; + + const scale = Math.min( + clientWidth / naturalWidth, + clientHeight / naturalHeight + ); + const width = Math.max(1, Math.round(naturalWidth * scale)); + const height = Math.max(1, Math.round(naturalHeight * scale)); + + Object.assign(element.style, { + width: `${width}px`, + height: `${height}px`, + position: "absolute", + top: "50%", + left: "50%", + marginTop: `-${height / 2}px`, + marginLeft: `-${width / 2}px`, + ...extraStyles, + }); + }, + [] + ); + + return { syncContainedElement }; +} + +export function useSyncedElement( + sourceRef: React.RefObject, + targetRef: React.RefObject, + containerRef: React.RefObject, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dependencies: any[] = [], + extraStyles: Partial = {} +) { + const { syncContainedElement } = useElementSync(); + + useEffect(() => { + const source = sourceRef.current; + const target = targetRef.current; + const container = containerRef.current; + + if (!source || !target || !container) return undefined; + + const sync = () => { + requestAnimationFrame(() => { + if (!source || !target || !container) return; + + // If target is a canvas, sync its internal resolution as well + if (target instanceof HTMLCanvasElement) { + target.width = source.naturalWidth; + target.height = source.naturalHeight; + } + + syncContainedElement( + target, + container, + source.naturalWidth, + source.naturalHeight, + extraStyles + ); + }); + }; + + const resizeObserver = new ResizeObserver(sync); + resizeObserver.observe(container); + + if (source.complete) sync(); + source.addEventListener("load", sync); + + return () => { + resizeObserver.disconnect(); + source.removeEventListener("load", sync); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [syncContainedElement, ...dependencies]); +} diff --git a/src/components/edit-window/hooks/useImageIO.ts b/src/components/edit-window/hooks/useImageIO.ts new file mode 100644 index 00000000..c56434db --- /dev/null +++ b/src/components/edit-window/hooks/useImageIO.ts @@ -0,0 +1,238 @@ +import React, { useState, useEffect, RefObject } from "react"; +import { listen, emit } from "@tauri-apps/api/event"; +import { readFile, writeFile, exists } from "@tauri-apps/plugin-fs"; +import { basename, extname, join, dirname } from "@tauri-apps/api/path"; +import { toast } from "sonner"; + +export function useImageIO( + imageRef: RefObject, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + t: any, + onImageLoad?: () => void +) { + const [imagePath, setImagePath] = useState(null); + const [imageUrl, setImageUrl] = useState(null); + const [imageName, setImageName] = useState(null); + const [imageSize, setImageSize] = useState<{ w: number; h: number } | null>( + null + ); + const [error, setError] = useState(null); + + const findUniqueFilePath = async ( + directory: string, + baseName: string, + timestamp: string, + extension: string, + initialPath: string + ): Promise => { + let fileExists = false; + try { + fileExists = await exists(initialPath); + } catch { + return initialPath; + } + if (!fileExists) return initialPath; + + const maxAttempts = 100; + const pathsToCheck: Promise<{ path: string; exists: boolean }>[] = []; + for (let i = 1; i <= maxAttempts; i += 1) { + const numberedFilename = `${baseName}_edited_${timestamp}_${i}${extension}`; + const numberedPathPromise = join(directory, numberedFilename); + pathsToCheck.push( + numberedPathPromise.then(path => + exists(path) + .then(exists => ({ path, exists })) + .catch(() => ({ path, exists: false })) + ) + ); + } + + const results = await Promise.all(pathsToCheck); + const firstAvailable = results.find(result => !result.exists); + return ( + firstAvailable?.path ?? + results[results.length - 1]?.path ?? + initialPath + ); + }; + + const processImageWithFilters = async ( + imgRef: React.RefObject, + brightnessValue: number, + contrastValue: number + ): Promise => { + if (!imgRef.current) throw new Error("Image not loaded"); + const img = imgRef.current; + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Failed to get canvas context"); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + if (brightnessValue !== 100 || contrastValue !== 100) { + ctx.filter = `brightness(${brightnessValue / 100}) contrast(${contrastValue / 100})`; + } + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + ctx.filter = "none"; + const editedBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + blob => + blob + ? resolve(blob) + : reject(new Error("Failed to convert canvas to blob")), + "image/png", + 1.0 + ); + }); + const arrayBuffer = await editedBlob.arrayBuffer(); + return new Uint8Array(arrayBuffer); + }; + + const generateFilename = async ( + currentPath: string + ): Promise<{ + nameWithoutExt: string; + extWithDot: string; + timestamp: string; + }> => { + const originalFilename = await basename(currentPath); + const extension = await extname(currentPath); + const extWithDot = extension + ? extension.startsWith(".") + ? extension + : `.${extension}` + : ".png"; + const lastDotIndex = originalFilename.lastIndexOf("."); + const nameWithoutExt = + lastDotIndex > 0 + ? originalFilename.slice(0, lastDotIndex) + : originalFilename; + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .slice(0, -5); + return { nameWithoutExt, extWithDot, timestamp }; + }; + + const loadImage = async (path: string) => { + try { + setError(null); + setImageUrl(null); + const imageBytes = await readFile(path); + const blob = new Blob([imageBytes]); + const url = URL.createObjectURL(blob); + setImageUrl(url); + setImageName(await basename(path)); + onImageLoad?.(); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to load image"; + setError(`${errorMessage} (Path: ${path})`); + setImageUrl(null); + } + }; + + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const pathFromUrl = urlParams.get("imagePath"); + if (pathFromUrl) { + const decodedPath = decodeURIComponent(pathFromUrl); + const normalizedPath = decodedPath.replace(/\//g, "\\"); + setImagePath(normalizedPath); + loadImage(normalizedPath); + } + + const setupListener = async () => { + return listen("image-path-changed", event => { + setImagePath(event.payload); + loadImage(event.payload); + }); + }; + + let unlistenPromise: Promise<() => void> | null = null; + setupListener().then(unlisten => { + unlistenPromise = Promise.resolve(unlisten); + }); + return () => { + unlistenPromise?.then(fn => fn()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + return () => { + if (imageUrl?.startsWith("blob:")) { + URL.revokeObjectURL(imageUrl); + } + }; + }, [imageUrl]); + + useEffect(() => { + const img = imageRef.current; + if (!img) return undefined; + const updateSize = () => { + setImageSize({ w: img.naturalWidth, h: img.naturalHeight }); + }; + if (img.complete && img.naturalWidth) updateSize(); + img.addEventListener("load", updateSize); + return () => img.removeEventListener("load", updateSize); + }, [imageUrl, imageRef]); + + const saveEditedImage = async (brightness: number, contrast: number) => { + if (!imageUrl || !imagePath) return; + try { + const uint8Array = await processImageWithFilters( + imageRef, + brightness, + contrast + ); + const { nameWithoutExt, extWithDot, timestamp } = + await generateFilename(imagePath); + const newFilename = `${nameWithoutExt}_edited_${timestamp}${extWithDot}`; + const imageDir = await dirname(imagePath); + const newImagePath = await join(imageDir, newFilename); + const finalPath = await findUniqueFilePath( + imageDir, + nameWithoutExt, + timestamp, + extWithDot, + newImagePath + ); + + await writeFile(finalPath, uint8Array); + const fileWasWritten = await exists(finalPath); + if (!fileWasWritten) + throw new Error(`File was not created at path: ${finalPath}`); + + await emit("image-reload-requested", { + originalPath: imagePath, + newPath: finalPath, + }); + setImagePath(finalPath); + setImageName(await basename(finalPath)); + + const blob = new Blob([uint8Array], { type: "image/png" }); + const url = URL.createObjectURL(blob); + setImageUrl(url); + toast.success(t("Image saved successfully", { ns: "tooltip" })); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : String(err); + toast.error( + t("Failed to save image: {{error}}", { + ns: "tooltip", + error: errorMessage, + }) + ); + } + }; + + return { + imagePath, + imageUrl, + setImageUrl, + imageName, + imageSize, + error, + saveEditedImage, + }; +} diff --git a/src/components/edit-window/hooks/useImagePanZoom.ts b/src/components/edit-window/hooks/useImagePanZoom.ts new file mode 100644 index 00000000..72d6a483 --- /dev/null +++ b/src/components/edit-window/hooks/useImagePanZoom.ts @@ -0,0 +1,93 @@ +import React, { useState, useCallback, RefObject } from "react"; + +export function useImagePanZoom( + containerRef: RefObject, + imageRef: RefObject, + isEnabled: boolean = true +) { + const [state, setState] = useState({ + zoom: 1, + pan: { x: 0, y: 0 }, + isDragging: false, + dragStart: { x: 0, y: 0 }, + }); + + const reset = useCallback(() => { + setState(s => ({ ...s, zoom: 1, pan: { x: 0, y: 0 } })); + }, []); + + const handleWheel = useCallback( + (e: React.WheelEvent | WheelEvent) => { + if (!isEnabled || !containerRef.current || !imageRef.current) + return; + e.preventDefault(); + const delta = (e as WheelEvent).deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(0.1, Math.min(10, state.zoom * delta)); + const rect = containerRef.current.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const mouseX = (e as MouseEvent).clientX - rect.left; + const mouseY = (e as MouseEvent).clientY - rect.top; + const imageX = (mouseX - centerX - state.pan.x) / state.zoom; + const imageY = (mouseY - centerY - state.pan.y) / state.zoom; + + setState(s => ({ + ...s, + zoom: newZoom, + pan: { + x: mouseX - centerX - imageX * newZoom, + y: mouseY - centerY - imageY * newZoom, + }, + })); + }, + [isEnabled, state.zoom, state.pan, containerRef, imageRef] + ); + + const handleMouseDown = useCallback( + ( + e: React.MouseEvent, + allowedButtons: number[] = [0] + ) => { + if (!allowedButtons.includes(e.button)) return; + setState(s => ({ + ...s, + isDragging: true, + dragStart: { x: e.clientX - s.pan.x, y: e.clientY - s.pan.y }, + })); + }, + [] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!state.isDragging) return; + setState(s => ({ + ...s, + pan: { + x: e.clientX - s.dragStart.x, + y: e.clientY - s.dragStart.y, + }, + })); + }, + [state.isDragging] + ); + + const handleMouseUp = useCallback(() => { + setState(s => ({ ...s, isDragging: false })); + }, []); + + const handleMiddleDrag = useCallback((dx: number, dy: number) => { + setState(s => ({ ...s, pan: { x: s.pan.x + dx, y: s.pan.y + dy } })); + }, []); + + return { + ...state, + reset, + setState, + handleWheel, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleMiddleDrag, + }; +} diff --git a/src/lib/fftProcessor.ts b/src/lib/fftProcessor.ts index c544ff2c..c4d13778 100644 --- a/src/lib/fftProcessor.ts +++ b/src/lib/fftProcessor.ts @@ -1,6 +1,6 @@ -/* eslint-disable no-plusplus, no-param-reassign, security/detect-object-injection, @typescript-eslint/no-non-null-assertion */ import FFT from "fft.js"; +/* eslint-disable */ export interface FFTResult { complexData: Float32Array; width: number; @@ -26,20 +26,27 @@ function imageToGrayscaleComplex( ): Float32Array { const data = new Float32Array(w * h * 2); const pixels = imageData.data; - const scaleX = imageData.width / w; - const scaleY = imageData.height / h; + const imgWidth = imageData.width; + const imgHeight = imageData.height; + // To prevent high-frequency artifacts (cuts/ringing) from nearest-neighbor stretching, + // we simply embed the image and use edge replication for padding borders. for (let y = 0; y < h; y++) { + const srcY = y < imgHeight ? y : imgHeight - 1; + const yOffset = srcY * imgWidth; + const outRow = y * w * 2; + for (let x = 0; x < w; x++) { - const p = - (Math.floor(y * scaleY) * imageData.width + - Math.floor(x * scaleX)) * - 4; + const srcX = x < imgWidth ? x : imgWidth - 1; + const p = (yOffset + srcX) * 4; + + // Weighted grayscale algorithm const gray = (pixels[p] ?? 0) * 0.299 + (pixels[p + 1] ?? 0) * 0.587 + (pixels[p + 2] ?? 0) * 0.114; - const idx = 2 * (y * w + x); + + const idx = outRow + x * 2; data[idx] = gray; data[idx + 1] = 0; } @@ -57,17 +64,18 @@ function fftRows( ): void { const input = new Float32Array(w * 2); const output = new Float32Array(w * 2); + const w2 = w * 2; for (let y = 0; y < h; y++) { - const off = y * w * 2; - for (let k = 0; k < w * 2; k++) input[k] = data[off + k]!; + const off = y * w2; + for (let k = 0; k < w2; k++) input[k] = data[off + k]!; if (inverse) { fftInstance.inverseTransform(output, input); - for (let k = 0; k < w * 2; k++) data[off + k] = output[k]! / w; + for (let k = 0; k < w2; k++) data[off + k] = output[k]! / w; } else { fftInstance.transform(output, input); - for (let k = 0; k < w * 2; k++) data[off + k] = output[k]!; + for (let k = 0; k < w2; k++) data[off + k] = output[k]!; } } } @@ -122,26 +130,46 @@ function computeSpectrum(data: Float32Array, w: number, h: number): Uint8Array { const m = i === 0 ? 0 : Math.log(1 + Math.sqrt(re * re + im * im)); mags[i] = m; if (i > 0) { - minMag = Math.min(minMag, m); - maxMag = Math.max(maxMag, m); + if (m < minMag) minMag = m; + if (m > maxMag) maxMag = m; } } if (maxMag <= minMag) maxMag = minMag + 1; const range = maxMag - minMag; - const halfW = w / 2; - const halfH = h / 2; + + // Ensure accurate integer offsets + const halfW = (w / 2) | 0; + const halfH = (h / 2) | 0; for (let y = 0; y < h; y++) { + // Fast boolean branches to eliminate extreme CPU overhead of Modulo division + const shiftY = y < halfH ? y + halfH : y - halfH; + const yOffsetDst = shiftY * w; + const yOffsetSrc = y * w; + for (let x = 0; x < w; x++) { - const dstIdx = (((y + halfH) % h) * w + ((x + halfW) % w)) * 4; - const val = Math.max( - 0, - Math.min(255, (((mags[y * w + x] ?? 0) - minMag) / range) * 255) - ); - spectrum[dstIdx] = val; - spectrum[dstIdx + 1] = val; - spectrum[dstIdx + 2] = val; + const shiftX = x < halfW ? x + halfW : x - halfW; + const dstIdx = (yOffsetDst + shiftX) * 4; + + // Direct mapping without layered nested function calls + const rawMag = mags[yOffsetSrc + x] ?? 0; + let normalizedFloat = (rawMag - minMag) / range; + + normalizedFloat = Math.pow(normalizedFloat, 3); + + let val = normalizedFloat * 255; + + // Fast physical bounds checks without Math.min/Math.max nested tree allocation + if (val < 0) val = 0; + if (val > 255) val = 255; + + // Fast Int truncation + const norm = val | 0; + + spectrum[dstIdx] = norm; + spectrum[dstIdx + 1] = norm; + spectrum[dstIdx + 2] = norm; spectrum[dstIdx + 3] = 255; } } @@ -156,6 +184,7 @@ function computeSpectrum(data: Float32Array, w: number, h: number): Uint8Array { * allows masking frequency components, and reconstructs * the filtered image via inverse FFT. */ + export class ImageFFT { private rowFFT: InstanceType; @@ -212,32 +241,70 @@ export class ImageFFT { /** Zero-out frequency components where the mask overlay has red brush strokes. */ applyMask( complexData: Float32Array, - maskData: Uint8ClampedArray + maskData: Uint8ClampedArray, + maskWidth: number, + maskHeight: number ): Float32Array { const filtered = new Float32Array(complexData); - const halfW = this.width / 2; - const halfH = this.height / 2; + const halfW = (this.width / 2) | 0; + const halfH = (this.height / 2) | 0; + + const scaleX = maskWidth / this.width; + const scaleY = maskHeight / this.height; for (let y = 0; y < this.height; y++) { + // y is the complexData un-shifted index (DC at 0) + const shiftY = y < halfH ? y + halfH : y - halfH; + + let my = (shiftY * scaleY) | 0; + if (my > maskHeight - 1) my = maskHeight - 1; + const myBase = my * maskWidth; + + const rowOffset = y * this.width; + for (let x = 0; x < this.width; x++) { - const shiftX = (x + halfW) % this.width; - const shiftY = (y + halfH) % this.height; - const maskIdx = (y * this.width + x) * 4; + const shiftX = x < halfW ? x + halfW : x - halfW; + + let mx = (shiftX * scaleX) | 0; + if (mx > maskWidth - 1) mx = maskWidth - 1; + + const maskIdx = (myBase + mx) * 4; const R = maskData[maskIdx] ?? 0; - const G = maskData[maskIdx + 1] ?? 0; - const B = maskData[maskIdx + 2] ?? 0; + const A = maskData[maskIdx + 3] ?? 0; + + if (A > 150 && R > 150) { + const G = maskData[maskIdx + 1] ?? 0; + const B = maskData[maskIdx + 2] ?? 0; + + const isRedBrush = G < 50 && B < 50; + + // Do not zero DC component + if (isRedBrush && !(x === 0 && y === 0)) { + const idx = 2 * (rowOffset + x); + filtered[idx] = 0; + filtered[idx + 1] = 0; + } + } + } + } - // Detect dark-red brush strokes (R > 150, G/B < 50) - const isRedBrush = R > 150 && G < 50 && B < 50; + // Enforce conjugate symmetry mathematically to ensure spatial result is strictly Real. + // This removes imaginary-phase ringing ("cuts") around drawn boundaries. + for (let y = 0; y < this.height; y++) { + const conjY = (this.height - y) % this.height; + for (let x = 0; x < this.width; x++) { + const idx = 2 * (y * this.width + x); + const conjX = (this.width - x) % this.width; + const conjIdx = 2 * (conjY * this.width + conjX); - if (isRedBrush && !(shiftX === 0 && shiftY === 0)) { - const idx = 2 * (shiftY * this.width + shiftX); - filtered[idx] = 0; - filtered[idx + 1] = 0; + if (filtered[idx] === 0 && filtered[idx + 1] === 0) { + filtered[conjIdx] = 0; + filtered[conjIdx + 1] = 0; } } } + return filtered; } @@ -262,23 +329,25 @@ export class ImageFFT { outputH: number ): ImageData { const result = new ImageData(outputW, outputH); - const scaleX = this.width / outputW; - const scaleY = this.height / outputH; let minVal = Infinity; let maxVal = -Infinity; const pixelValues = new Float32Array(outputW * outputH); for (let y = 0; y < outputH; y++) { + // We placed the image without stretching, so read it back 1:1 + const yOffset = y * this.width; + const outRow = y * outputW; + for (let x = 0; x < outputW; x++) { - const srcX = Math.min(Math.floor(x * scaleX), this.width - 1); - const srcY = Math.min(Math.floor(y * scaleY), this.height - 1); - const idx = 2 * (srcY * this.width + srcX); - const re = data[idx]!; - const im = data[idx + 1]!; - const val = Math.sqrt(re * re + im * im); - pixelValues[y * outputW + x] = val; - minVal = Math.min(minVal, val); - maxVal = Math.max(maxVal, val); + const idx = 2 * (yOffset + x); + + // Directly map the real component. + // Enforcing inverse mask symmetry ensures the imaginary component is safely ~0. + const val = data[idx]!; + + pixelValues[outRow + x] = val; + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; } } @@ -286,12 +355,19 @@ export class ImageFFT { const resRange = maxVal - minVal; for (let i = 0; i < pixelValues.length; i++) { - const normalized = + let normalized = (((pixelValues[i] ?? 0) - minVal) / resRange) * 255; + + if (normalized < 0) normalized = 0; + if (normalized > 255) normalized = 255; + + // Integer fast cast + const normInt = normalized | 0; + const pIdx = i * 4; - result.data[pIdx] = normalized; - result.data[pIdx + 1] = normalized; - result.data[pIdx + 2] = normalized; + result.data[pIdx] = normInt; + result.data[pIdx + 1] = normInt; + result.data[pIdx + 2] = normInt; result.data[pIdx + 3] = 255; } diff --git a/src/lib/locales/en/keywords.ts b/src/lib/locales/en/keywords.ts index 216ea098..405a31b8 100644 --- a/src/lib/locales/en/keywords.ts +++ b/src/lib/locales/en/keywords.ts @@ -74,6 +74,12 @@ const d: Dictionary = { Edit: "Edit", Apply: "Apply", Clear: "Clear", + Draw: "Draw", + Pan: "Pan", + Eraser: "Eraser", + Shape: "Shape", + Round: "Round", + Oval: "Oval", }; export default d; diff --git a/src/lib/locales/pl/keywords.ts b/src/lib/locales/pl/keywords.ts index 02546618..fa49e87e 100644 --- a/src/lib/locales/pl/keywords.ts +++ b/src/lib/locales/pl/keywords.ts @@ -74,6 +74,12 @@ const d: Dictionary = { Edit: "Edycja", Apply: "Zastosuj", Clear: "Wyczyść", + Draw: "Rysuj", + Pan: "Przesuwaj", + Eraser: "Gumka", + Shape: "Kształt", + Round: "Okrągły", + Oval: "Owalny", }; export default d; diff --git a/src/lib/locales/translation.ts b/src/lib/locales/translation.ts index f4a90ea9..c6430445 100644 --- a/src/lib/locales/translation.ts +++ b/src/lib/locales/translation.ts @@ -83,6 +83,12 @@ export type i18nKeywords = Recordify< | "Edit" | "Apply" | "Clear" + | "Draw" + | "Pan" + | "Eraser" + | "Shape" + | "Round" + | "Oval" >; export type i18nDescription = Recordify< From bf2d24a4176879b06cc4a7543ff2bcdd9e40a3bc Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:52:01 +0200 Subject: [PATCH 02/15] refactor: moved hooks to appropriate location --- src/components/edit-window/components/SidebarFFT.tsx | 2 +- src/components/edit-window/edit-window.tsx | 2 +- src/components/edit-window/fft/ImagePanes.tsx | 8 ++++---- src/components/edit-window/{fft => hooks}/useFftInit.ts | 2 +- .../edit-window/{fft => hooks}/useFftPainter.ts | 4 ++-- .../edit-window/{fft => hooks}/useFftWorkspace.ts | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) rename src/components/edit-window/{fft => hooks}/useFftInit.ts (99%) rename src/components/edit-window/{fft => hooks}/useFftPainter.ts (98%) rename src/components/edit-window/{fft => hooks}/useFftWorkspace.ts (97%) diff --git a/src/components/edit-window/components/SidebarFFT.tsx b/src/components/edit-window/components/SidebarFFT.tsx index 75c6668d..78d64e79 100644 --- a/src/components/edit-window/components/SidebarFFT.tsx +++ b/src/components/edit-window/components/SidebarFFT.tsx @@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils/shadcn"; import { Edit3, Hand, Waves, Trash2 } from "lucide-react"; import { ICON } from "@/lib/utils/const"; -import { UseFftWorkspaceReturn } from "../fft/useFftWorkspace"; +import { UseFftWorkspaceReturn } from "../hooks/useFftWorkspace"; interface SidebarFFTProps { isFftActive: boolean; diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index 4a1ef88a..56ebcb7d 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -8,7 +8,7 @@ import { Edit } from "lucide-react"; import { useSettingsSync } from "@/lib/hooks/useSettingsSync"; import ImageDpiControls from "./dpi/image-dpi-controls"; import ImagePanes from "./fft/ImagePanes"; -import { useFftWorkspace } from "./fft/useFftWorkspace"; +import { useFftWorkspace } from "./hooks/useFftWorkspace"; import { useImagePanZoom } from "./hooks/useImagePanZoom"; import { useImageIO } from "./hooks/useImageIO"; diff --git a/src/components/edit-window/fft/ImagePanes.tsx b/src/components/edit-window/fft/ImagePanes.tsx index 21b60760..9bd60bac 100644 --- a/src/components/edit-window/fft/ImagePanes.tsx +++ b/src/components/edit-window/fft/ImagePanes.tsx @@ -12,7 +12,7 @@ interface ImagePanesProps { isFftActive: boolean; fftStatus: FftStatus; - // left pane — original image + // left pane — FFT editor containerRef: RefObject; imageRef: RefObject; spectrumCanvasRef: RefObject; @@ -28,7 +28,7 @@ interface ImagePanesProps { onDoubleClick: () => void; onResetZoom: () => void; - // right pane — FFT preview + // right pane — FFT output fftContainerRef: RefObject; previewCanvasRef: RefObject; rightPanZoom: number; @@ -87,7 +87,7 @@ function ImagePanes({ isFftActive ? "gap-4" : "gap-0" )} > - {/* ── Left pane: original image ── */} + {/* ── Left pane: fft editor ── */}
; From 62288b03a41e3eeb7f0b4d7e269c2226fe455d7c Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:12:48 +0200 Subject: [PATCH 03/15] fix: panes load on click instead of showing up on load --- src/components/edit-window/hooks/useElementSync.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/edit-window/hooks/useElementSync.ts b/src/components/edit-window/hooks/useElementSync.ts index 92378c03..11290881 100644 --- a/src/components/edit-window/hooks/useElementSync.ts +++ b/src/components/edit-window/hooks/useElementSync.ts @@ -61,8 +61,12 @@ export function useSyncedElement( // If target is a canvas, sync its internal resolution as well if (target instanceof HTMLCanvasElement) { - target.width = source.naturalWidth; - target.height = source.naturalHeight; + if (target.width !== source.naturalWidth) { + target.width = source.naturalWidth; + } + if (target.height !== source.naturalHeight) { + target.height = source.naturalHeight; + } } syncContainedElement( From 663a31886b0c204b716eac689eec41562c9b8588 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 15:58:01 +0200 Subject: [PATCH 04/15] fix: use react's type --- src/components/edit-window/hooks/useElementSync.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/edit-window/hooks/useElementSync.ts b/src/components/edit-window/hooks/useElementSync.ts index 11290881..0108cf78 100644 --- a/src/components/edit-window/hooks/useElementSync.ts +++ b/src/components/edit-window/hooks/useElementSync.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { DependencyList, useCallback, useEffect } from "react"; export function useElementSync() { const syncContainedElement = useCallback( @@ -42,8 +42,7 @@ export function useSyncedElement( sourceRef: React.RefObject, targetRef: React.RefObject, containerRef: React.RefObject, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dependencies: any[] = [], + dependencies: DependencyList = [], extraStyles: Partial = {} ) { const { syncContainedElement } = useElementSync(); From db8963c449f50dd64a665350fcd6fa815cc09e69 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 16:08:34 +0200 Subject: [PATCH 05/15] fix: proper 't' type --- src/components/edit-window/hooks/useImageIO.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/edit-window/hooks/useImageIO.ts b/src/components/edit-window/hooks/useImageIO.ts index c56434db..fce2693a 100644 --- a/src/components/edit-window/hooks/useImageIO.ts +++ b/src/components/edit-window/hooks/useImageIO.ts @@ -3,11 +3,11 @@ import { listen, emit } from "@tauri-apps/api/event"; import { readFile, writeFile, exists } from "@tauri-apps/plugin-fs"; import { basename, extname, join, dirname } from "@tauri-apps/api/path"; import { toast } from "sonner"; +import type { TFunction } from "i18next"; export function useImageIO( imageRef: RefObject, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - t: any, + t: TFunction<"tooltip">, onImageLoad?: () => void ) { const [imagePath, setImagePath] = useState(null); From 2be176c238adb439ff0c2e7ad3803892bbf0cfae Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 16:27:01 +0200 Subject: [PATCH 06/15] fix: proper styling when drawing mode is active --- src/components/edit-window/components/SidebarFFT.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/edit-window/components/SidebarFFT.tsx b/src/components/edit-window/components/SidebarFFT.tsx index 78d64e79..75f9c096 100644 --- a/src/components/edit-window/components/SidebarFFT.tsx +++ b/src/components/edit-window/components/SidebarFFT.tsx @@ -59,7 +59,7 @@ export function SidebarFFT({ } className={cn( "flex-1 flex items-center justify-center gap-2 py-1.5 px-2 rounded-md transition-all text-xs font-medium", - fft.interactionMode !== "pan" + fft.interactionMode === "draw" ? "bg-background shadow-sm text-foreground" : "text-muted-foreground hover:bg-secondary/80 hover:text-secondary-foreground" )} From 3f069784fa87828a8b871d1c2f8a2e01244b84dc Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 16:43:06 +0200 Subject: [PATCH 07/15] fix: race condition; cleanup no longer depends on the listener --- src/components/edit-window/hooks/useImageIO.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/edit-window/hooks/useImageIO.ts b/src/components/edit-window/hooks/useImageIO.ts index fce2693a..9f222642 100644 --- a/src/components/edit-window/hooks/useImageIO.ts +++ b/src/components/edit-window/hooks/useImageIO.ts @@ -148,14 +148,10 @@ export function useImageIO( }); }; - let unlistenPromise: Promise<() => void> | null = null; - setupListener().then(unlisten => { - unlistenPromise = Promise.resolve(unlisten); - }); + const unlistenPromise = setupListener(); return () => { - unlistenPromise?.then(fn => fn()); + unlistenPromise.then(unlisten => unlisten()); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { From 43e90ff82cf9826e53917a4828f160c458483800 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 16:57:33 +0200 Subject: [PATCH 08/15] fix: depend on useFFtInit instead of direct state change --- .../edit-window/hooks/useElementSync.ts | 1 - .../edit-window/hooks/useFftWorkspace.ts | 50 +++++++++---------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/components/edit-window/hooks/useElementSync.ts b/src/components/edit-window/hooks/useElementSync.ts index 0108cf78..fee8c31d 100644 --- a/src/components/edit-window/hooks/useElementSync.ts +++ b/src/components/edit-window/hooks/useElementSync.ts @@ -88,6 +88,5 @@ export function useSyncedElement( resizeObserver.disconnect(); source.removeEventListener("load", sync); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [syncContainedElement, ...dependencies]); } diff --git a/src/components/edit-window/hooks/useFftWorkspace.ts b/src/components/edit-window/hooks/useFftWorkspace.ts index a0b1f088..5b7961a2 100644 --- a/src/components/edit-window/hooks/useFftWorkspace.ts +++ b/src/components/edit-window/hooks/useFftWorkspace.ts @@ -50,6 +50,7 @@ export function useFftWorkspace({ const maskCanvasRef = useRef(null); const specCanvasRef = useRef(null); const originalDimsRef = useRef({ w: 0, h: 0 }); + const readyRedrawFrameRef = useRef(null); const brushSizeRef = useRef(brushSize); const brushShapeRef = useRef(brushShape); @@ -100,6 +101,29 @@ export function useFftWorkspace({ if (ctx) ctx.putImageData(resultImage, 0, 0); }, [previewCanvasRef]); + const redrawAfterInit = useCallback(() => { + doRedrawOverlay(); + updateLivePreview(); + + if (readyRedrawFrameRef.current !== null) { + cancelAnimationFrame(readyRedrawFrameRef.current); + } + + readyRedrawFrameRef.current = requestAnimationFrame(() => { + readyRedrawFrameRef.current = null; + doRedrawOverlay(); + updateLivePreview(); + }); + }, [doRedrawOverlay, updateLivePreview]); + + useEffect(() => { + return () => { + if (readyRedrawFrameRef.current !== null) { + cancelAnimationFrame(readyRedrawFrameRef.current); + } + }; + }, []); + useFftInit({ isActive, imageRef, @@ -108,10 +132,7 @@ export function useFftWorkspace({ refs, onToggleActive, onStatusChange: setStatus, - onReady: () => { - doRedrawOverlay(); - updateLivePreview(); - }, + onReady: redrawAfterInit, }); useFftPainter({ @@ -129,27 +150,6 @@ export function useFftWorkspace({ onMiddleDrag, }); - useEffect(() => { - if (!isActive || status !== "ready") return undefined; - let rafId = 0; - let nestedRafId = 0; - - rafId = requestAnimationFrame(() => { - doRedrawOverlay(); - updateLivePreview(); - - nestedRafId = requestAnimationFrame(() => { - doRedrawOverlay(); - updateLivePreview(); - }); - }); - - return () => { - cancelAnimationFrame(rafId); - cancelAnimationFrame(nestedRafId); - }; - }, [isActive, status, doRedrawOverlay, updateLivePreview]); - const applyFilter = useCallback(() => { const outCvs = previewCanvasRef.current; if (!outCvs) return; From 25a624081d2a55e12c4dcfeaf96c2bc4025037c6 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 17:07:25 +0200 Subject: [PATCH 09/15] refactor: more explicit depenendcies in dependency array --- src/components/edit-window/edit-window.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index 56ebcb7d..bf7b0a2a 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -48,12 +48,13 @@ function EditWindowContent({ const fftCanvasRef = providedPreviewCanvasRef ?? internalPreviewCanvasRef; const left = useImagePanZoom(containerRef, imageRef, true); const right = useImagePanZoom(fftContainerRef, fftCanvasRef, isFftActive); + const resetLeft = left.reset; + const resetRight = right.reset; - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { - left.reset(); - right.reset(); - }, [isFftActive]); + resetLeft(); + resetRight(); + }, [isFftActive, resetLeft, resetRight]); const { imagePath, @@ -64,18 +65,18 @@ function EditWindowContent({ error, saveEditedImage, } = useImageIO(imageRef, t, () => { - left.reset(); - right.reset(); + resetLeft(); + resetRight(); }); const handleFftApply = useCallback( (dataUrl: string) => { setImageUrl(dataUrl); onFftApply?.(dataUrl); - left.reset(); - right.reset(); + resetLeft(); + resetRight(); }, - [onFftApply, setImageUrl, left, right] + [onFftApply, setImageUrl, resetLeft, resetRight] ); const fft = useFftWorkspace({ From 177664fe3a137530ff4f3ed6fecef643ee1013d4 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 17:12:57 +0200 Subject: [PATCH 10/15] fix: unnecessary component indirection --- src/components/edit-window/edit-window.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index bf7b0a2a..2b9e2ea3 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -25,7 +25,7 @@ interface EditWindowContentProps { onFftApply?: (dataUrl: string) => void; } -function EditWindowContent({ +export function EditWindow({ imageRef: providedImageRef, spectrumCanvasRef: providedSpectrumCanvasRef, previewCanvasRef: providedPreviewCanvasRef, @@ -234,10 +234,6 @@ function EditWindowContent({ ); } -export function EditWindow() { - return ; -} - export function EditWindowWithProps({ imageRef, spectrumCanvasRef, @@ -250,7 +246,7 @@ export function EditWindowWithProps({ onApply: (dataUrl: string) => void; }) { return ( - Date: Tue, 5 May 2026 17:29:51 +0200 Subject: [PATCH 11/15] fix: removed unused props wrapper --- src/components/edit-window/edit-window.tsx | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index 2b9e2ea3..0fe966be 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -233,24 +233,3 @@ export function EditWindow({ ); } - -export function EditWindowWithProps({ - imageRef, - spectrumCanvasRef, - previewCanvasRef, - onApply, -}: { - imageRef: React.RefObject; - spectrumCanvasRef: React.RefObject; - previewCanvasRef: React.RefObject; - onApply: (dataUrl: string) => void; -}) { - return ( - - ); -} From 32346f8ec6bbe7934d3c057796ec6dc093e06b87 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 18:03:48 +0200 Subject: [PATCH 12/15] fix: removed git conflict markers -?? --- src/lib/locales/en/keywords.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/locales/en/keywords.ts b/src/lib/locales/en/keywords.ts index 15f06473..9c6698b5 100644 --- a/src/lib/locales/en/keywords.ts +++ b/src/lib/locales/en/keywords.ts @@ -74,20 +74,17 @@ const d: Dictionary = { Edit: "Edit", Apply: "Apply", Clear: "Clear", -<<<<<<< master Draw: "Draw", Pan: "Pan", Eraser: "Eraser", Shape: "Shape", Round: "Round", Oval: "Oval", -======= "Select working mode": "Select working mode", "No marking types found for the selected working mode": "No marking types found for the selected working mode", "Select a working mode to view marking types": "Select a working mode to view marking types", ->>>>>>> master }; export default d; From a3f85380ae121130ee03e2786f42487cc99fa620 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 18:05:16 +0200 Subject: [PATCH 13/15] fix: removed git conflict markers from pl locales --- src/lib/locales/pl/keywords.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/locales/pl/keywords.ts b/src/lib/locales/pl/keywords.ts index cbb344fb..5adeb7b1 100644 --- a/src/lib/locales/pl/keywords.ts +++ b/src/lib/locales/pl/keywords.ts @@ -74,20 +74,17 @@ const d: Dictionary = { Edit: "Edycja", Apply: "Zastosuj", Clear: "Wyczyść", -<<<<<<< master Draw: "Rysuj", Pan: "Przesuwaj", Eraser: "Gumka", Shape: "Kształt", Round: "Okrągły", Oval: "Owalny", -======= "Select working mode": "Wybierz tryb pracy", "No marking types found for the selected working mode": "Nie znaleziono typów adnotacji dla wybranego trybu pracy", "Select a working mode to view marking types": "Wybierz tryb pracy, aby wyświetlić typy adnotacji", ->>>>>>> master }; export default d; From f0cbaf4d25f8db207d7fe5d24fbaa2f556995449 Mon Sep 17 00:00:00 2001 From: Patryk <97395198+ihasp@users.noreply.github.com> Date: Tue, 5 May 2026 18:38:13 +0200 Subject: [PATCH 14/15] fix: make the dpi work with new refactor, disable dpi and sliders when fft editor is active --- .../edit-window/dpi/image-dpi-controls.tsx | 14 +++++++++++++- src/components/edit-window/edit-window.tsx | 9 +++++++-- src/components/edit-window/fft/ImagePanes.tsx | 14 ++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/components/edit-window/dpi/image-dpi-controls.tsx b/src/components/edit-window/dpi/image-dpi-controls.tsx index 09352d23..c4d24e52 100644 --- a/src/components/edit-window/dpi/image-dpi-controls.tsx +++ b/src/components/edit-window/dpi/image-dpi-controls.tsx @@ -8,15 +8,23 @@ import { ImageDpiCalibration } from "./imageDpiCalibration"; interface ImageDpiControlsProps { imageRef: RefObject; canvasRef: RefObject; + disabled?: boolean; } export default function ImageDpiControls({ imageRef, canvasRef, + disabled = false, }: ImageDpiControlsProps) { const [active, setActive] = useState(false); const [targetDpi, setTargetDpi] = useState<500 | 1000>(1000); const handlerRef = useRef(null); + // Disable dpi when fft editor is active + useEffect(() => { + if (disabled && active) { + setActive(false); + } + }, [disabled, active]); useEffect(() => { const canvas = canvasRef.current; @@ -49,6 +57,7 @@ export default function ImageDpiControls({ onClick={() => setActive(prev => !prev)} variant={active ? "destructive" : "default"} className="flex items-center justify-center gap-2" + disabled={disabled} > DPI @@ -66,7 +75,9 @@ export default function ImageDpiControls({ "flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 transition", targetDpi === dpi ? "border-primary bg-primary/10" - : "border-border hover:bg-muted" + : "border-border hover:bg-muted", + disabled && + "opacity-50 pointer-events-none cursor-not-allowed" )} > setTargetDpi(dpi)} + disabled={disabled} /> {dpi} DPI diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index 0fe966be..7d8dc114 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -43,6 +43,7 @@ export function EditWindow({ const internalSpectrumCanvasRef = useRef(null); const internalPreviewCanvasRef = useRef(null); const fftContainerRef = useRef(null); + const dpiCanvasRef = useRef(null); const imageRef = providedImageRef ?? internalImageRef; const canvasRef = providedSpectrumCanvasRef ?? internalSpectrumCanvasRef; const fftCanvasRef = providedPreviewCanvasRef ?? internalPreviewCanvasRef; @@ -92,6 +93,7 @@ export function EditWindow({ useSyncedElement(imageRef, imageRef, containerRef, [imageUrl]); useSyncedElement(imageRef, canvasRef, containerRef, [imageUrl]); + useSyncedElement(imageRef, dpiCanvasRef, containerRef, [imageUrl]); useSyncedElement( imageRef, fftCanvasRef, @@ -152,6 +154,7 @@ export function EditWindow({ containerRef={containerRef} imageRef={imageRef} spectrumCanvasRef={canvasRef} + dpiCanvasRef={dpiCanvasRef} brightness={brightness} contrast={contrast} zoom={left.zoom} @@ -206,7 +209,8 @@ export function EditWindow({ setBrightness={setBrightness} contrast={contrast} setContrast={setContrast} - disabled={!imageUrl} + // Disable when fft editor is active + disabled={!imageUrl || isFftActive} />
@@ -217,7 +221,8 @@ export function EditWindow({
diff --git a/src/components/edit-window/fft/ImagePanes.tsx b/src/components/edit-window/fft/ImagePanes.tsx index 9bd60bac..44aacced 100644 --- a/src/components/edit-window/fft/ImagePanes.tsx +++ b/src/components/edit-window/fft/ImagePanes.tsx @@ -16,6 +16,7 @@ interface ImagePanesProps { containerRef: RefObject; imageRef: RefObject; spectrumCanvasRef: RefObject; + dpiCanvasRef: RefObject; brightness: number; contrast: number; zoom: number; @@ -52,6 +53,7 @@ function ImagePanes({ containerRef, imageRef, spectrumCanvasRef, + dpiCanvasRef, brightness, contrast, zoom, @@ -148,6 +150,18 @@ function ImagePanes({ : "transform 0.1s ease-out, opacity 0.35s ease-out", }} /> + {/* 'dpi canvas' */} +
Date: Tue, 5 May 2026 19:12:56 +0200 Subject: [PATCH 15/15] refactor: organized imports --- src/components/edit-window/edit-window.tsx | 14 +++++++------- src/components/edit-window/hooks/useFftInit.ts | 2 +- src/components/edit-window/hooks/useFftPainter.ts | 4 ++-- .../edit-window/hooks/useFftWorkspace.ts | 6 +++--- src/components/edit-window/hooks/useImageIO.ts | 10 +++++----- .../edit-window/hooks/useImagePanZoom.ts | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/components/edit-window/edit-window.tsx b/src/components/edit-window/edit-window.tsx index 7d8dc114..63b46ac5 100644 --- a/src/components/edit-window/edit-window.tsx +++ b/src/components/edit-window/edit-window.tsx @@ -1,22 +1,22 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; -import { useTranslation } from "react-i18next"; import { WindowControls } from "@/components/menu/window-controls"; import { Menubar } from "@/components/ui/menubar"; -import { cn } from "@/lib/utils/shadcn"; +import { useSettingsSync } from "@/lib/hooks/useSettingsSync"; import { ICON } from "@/lib/utils/const"; +import { cn } from "@/lib/utils/shadcn"; import { Edit } from "lucide-react"; -import { useSettingsSync } from "@/lib/hooks/useSettingsSync"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import ImageDpiControls from "./dpi/image-dpi-controls"; import ImagePanes from "./fft/ImagePanes"; import { useFftWorkspace } from "./hooks/useFftWorkspace"; -import { useImagePanZoom } from "./hooks/useImagePanZoom"; -import { useImageIO } from "./hooks/useImageIO"; import { useSyncedElement } from "./hooks/useElementSync"; +import { useImageIO } from "./hooks/useImageIO"; +import { useImagePanZoom } from "./hooks/useImagePanZoom"; import { SidebarAdjustments } from "./components/SidebarAdjustments"; -import { SidebarTools } from "./components/SidebarTools"; import { SidebarFFT } from "./components/SidebarFFT"; +import { SidebarTools } from "./components/SidebarTools"; interface EditWindowContentProps { imageRef?: React.RefObject; diff --git a/src/components/edit-window/hooks/useFftInit.ts b/src/components/edit-window/hooks/useFftInit.ts index 851eed9c..f579ec3d 100644 --- a/src/components/edit-window/hooks/useFftInit.ts +++ b/src/components/edit-window/hooks/useFftInit.ts @@ -1,7 +1,7 @@ /* eslint-disable no-param-reassign */ /* eslint-disable no-console */ -import React, { RefObject, useEffect } from "react"; import { ImageFFT, type FFTResult } from "@/lib/fftProcessor"; +import React, { RefObject, useEffect } from "react"; import { FftStatus } from "../fft/fftTypes"; // this file contains fft lifecycle diff --git a/src/components/edit-window/hooks/useFftPainter.ts b/src/components/edit-window/hooks/useFftPainter.ts index 0800ff64..9dffcfcc 100644 --- a/src/components/edit-window/hooks/useFftPainter.ts +++ b/src/components/edit-window/hooks/useFftPainter.ts @@ -1,7 +1,7 @@ -import React, { RefObject, useEffect, useRef } from "react"; import { FFTResult } from "@/lib/fftProcessor"; -import { BrushShape, FftStatus, InteractionMode } from "../fft/fftTypes"; +import React, { RefObject, useEffect, useRef } from "react"; import { getCanvasCoords } from "../fft/fftCanvasUtils"; +import { BrushShape, FftStatus, InteractionMode } from "../fft/fftTypes"; // this file contains mouse event lifecycle diff --git a/src/components/edit-window/hooks/useFftWorkspace.ts b/src/components/edit-window/hooks/useFftWorkspace.ts index 5b7961a2..05fe3233 100644 --- a/src/components/edit-window/hooks/useFftWorkspace.ts +++ b/src/components/edit-window/hooks/useFftWorkspace.ts @@ -1,9 +1,9 @@ -import { RefObject, useCallback, useEffect, useRef, useState } from "react"; import { ImageFFT, type FFTResult } from "@/lib/fftProcessor"; +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { redrawFftOverlay } from "../fft/fftCanvasUtils"; +import { BrushShape, FftStatus, InteractionMode } from "../fft/fftTypes"; import { useFftInit, type FftRefs } from "./useFftInit"; import { useFftPainter } from "./useFftPainter"; -import { redrawFftOverlay } from "../fft/fftCanvasUtils"; -import { FftStatus, InteractionMode, BrushShape } from "../fft/fftTypes"; export interface UseFftWorkspaceProps { imageRef: RefObject; diff --git a/src/components/edit-window/hooks/useImageIO.ts b/src/components/edit-window/hooks/useImageIO.ts index 9f222642..ade59fd7 100644 --- a/src/components/edit-window/hooks/useImageIO.ts +++ b/src/components/edit-window/hooks/useImageIO.ts @@ -1,9 +1,9 @@ -import React, { useState, useEffect, RefObject } from "react"; -import { listen, emit } from "@tauri-apps/api/event"; -import { readFile, writeFile, exists } from "@tauri-apps/plugin-fs"; -import { basename, extname, join, dirname } from "@tauri-apps/api/path"; -import { toast } from "sonner"; +import { emit, listen } from "@tauri-apps/api/event"; +import { basename, dirname, extname, join } from "@tauri-apps/api/path"; +import { exists, readFile, writeFile } from "@tauri-apps/plugin-fs"; import type { TFunction } from "i18next"; +import React, { RefObject, useEffect, useState } from "react"; +import { toast } from "sonner"; export function useImageIO( imageRef: RefObject, diff --git a/src/components/edit-window/hooks/useImagePanZoom.ts b/src/components/edit-window/hooks/useImagePanZoom.ts index 72d6a483..01462449 100644 --- a/src/components/edit-window/hooks/useImagePanZoom.ts +++ b/src/components/edit-window/hooks/useImagePanZoom.ts @@ -1,4 +1,4 @@ -import React, { useState, useCallback, RefObject } from "react"; +import React, { RefObject, useCallback, useState } from "react"; export function useImagePanZoom( containerRef: RefObject,