diff --git a/src/components/adjustments/Basic.tsx b/src/components/adjustments/Basic.tsx index 94da62967..0c9ff04d1 100644 --- a/src/components/adjustments/Basic.tsx +++ b/src/components/adjustments/Basic.tsx @@ -2,7 +2,7 @@ import { motion } from 'framer-motion'; import clsx from 'clsx'; import Slider from '../ui/Slider'; import { Adjustments, BasicAdjustment } from '../../utils/adjustments'; -import { useEffect, useRef, useState } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; interface BasicAdjustmentsProps { adjustments: Adjustments; @@ -140,7 +140,7 @@ const ToneMapperSwitch = ({ ); }; -export default function BasicAdjustments({ +function BasicAdjustments({ adjustments, setAdjustments, isForMask = false, @@ -237,3 +237,6 @@ export default function BasicAdjustments({ ); } + +export default memo(BasicAdjustments); + diff --git a/src/components/adjustments/Color.tsx b/src/components/adjustments/Color.tsx index ae8733fab..c86abb84f 100644 --- a/src/components/adjustments/Color.tsx +++ b/src/components/adjustments/Color.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useState } from 'react'; import { Pipette, Sliders } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import Slider from '../ui/Slider'; @@ -385,7 +385,7 @@ const ColorCalibrationPanel = ({ adjustments, setAdjustments, onDragStateChange ); }; -export default function ColorPanel({ +function ColorPanel({ adjustments, setAdjustments, appSettings, @@ -553,3 +553,6 @@ export default function ColorPanel({ ); } + +export default memo(ColorPanel); + diff --git a/src/components/adjustments/Curves.tsx b/src/components/adjustments/Curves.tsx index 0a0ef5514..d46b14d8d 100644 --- a/src/components/adjustments/Curves.tsx +++ b/src/components/adjustments/Curves.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react'; +import { memo, useMemo, useState, useRef, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { RotateCcw, Copy, ClipboardPaste } from 'lucide-react'; import { ActiveChannel, Adjustments, Coord } from '../../utils/adjustments'; @@ -149,7 +149,7 @@ function isDefaultCurve(points: Array | undefined) { return p1.x === 0 && p1.y === 0 && p2.x === 255 && p2.y === 255; } -export default function CurveGraph({ +function CurveGraph({ adjustments, setAdjustments, histogram, @@ -288,6 +288,8 @@ export default function CurveGraph({ const points = localPoints ?? propPoints; const { color, data: histogramData } = channelConfig[activeChannel]; + const curvePath = useMemo(() => (points ? getCurvePath(points) : ''), [points]); + if (!propPoints || !points) { return ( - + {points.map((p: Coord, i: number) => ( ); } + +export default memo(CurveGraph); + diff --git a/src/components/adjustments/Details.tsx b/src/components/adjustments/Details.tsx index 32852cb81..b87b0e84a 100644 --- a/src/components/adjustments/Details.tsx +++ b/src/components/adjustments/Details.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import Slider from '../ui/Slider'; import { Adjustments, DetailsAdjustment } from '../../utils/adjustments'; import { AppSettings } from '../ui/AppProperties'; @@ -12,7 +13,7 @@ interface DetailsPanelProps { onDragStateChange?: (isDragging: boolean) => void; } -export default function DetailsPanel({ +function DetailsPanel({ adjustments, setAdjustments, appSettings, @@ -145,3 +146,6 @@ export default function DetailsPanel({ ); } + +export default memo(DetailsPanel); + diff --git a/src/components/adjustments/Effects.tsx b/src/components/adjustments/Effects.tsx index e680a8fda..e51bca1a9 100644 --- a/src/components/adjustments/Effects.tsx +++ b/src/components/adjustments/Effects.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import Slider from '../ui/Slider'; import { Adjustments, Effect, CreativeAdjustment } from '../../utils/adjustments'; import LUTControl from '../ui/LUTControl'; @@ -14,7 +15,7 @@ interface EffectsPanelProps { onDragStateChange?: (isDragging: boolean) => void; } -export default function EffectsPanel({ +function EffectsPanel({ adjustments, setAdjustments, isForMask = false, @@ -191,3 +192,6 @@ export default function EffectsPanel({ ); } + +export default memo(EffectsPanel); + diff --git a/src/components/panel/editor/Waveform.tsx b/src/components/panel/editor/Waveform.tsx index fab02e5c3..4dc73a1a0 100644 --- a/src/components/panel/editor/Waveform.tsx +++ b/src/components/panel/editor/Waveform.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo, memo } from 'react'; import { motion, AnimatePresence, LayoutGroup } from 'framer-motion'; import { AlertOctagon } from 'lucide-react'; import { WaveformData } from '../../ui/AppProperties'; @@ -46,28 +46,48 @@ const modeButtons = [ }, ]; -const HistogramView = ({ histogram }: { histogram: any }) => { - if (!histogram || !histogram.red || !histogram.green || !histogram.blue) return null; +const HistogramView = memo(({ histogram }: { histogram: any }) => { + const channels = useMemo(() => { + if (!histogram?.red || !histogram?.green || !histogram?.blue) return null; - const redMax = Math.max(...(histogram.red || [0])); - const greenMax = Math.max(...(histogram.green || [0])); - const blueMax = Math.max(...(histogram.blue || [0])); - const globalMax = Math.max(redMax, greenMax, blueMax, 1); + const getSafeMax = (arr: number[]) => { + let max = 0; + for (let i = 0; i < arr.length; i++) { + if (arr[i] > max) max = arr[i]; + } + return max || 1; + }; - const getFill = (data: number[]) => { - const pathData = data.map((val, i) => `${(i / 255) * 255},${255 - (val / globalMax) * 255}`).join(' L'); - return `M0,255 L${pathData} L255,255 Z`; - }; + const redMax = getSafeMax(histogram.red); + const greenMax = getSafeMax(histogram.green); + const blueMax = getSafeMax(histogram.blue); + const globalMax = Math.max(redMax, greenMax, blueMax); - const getLine = (data: number[]) => { - return 'M' + data.map((val, i) => `${(i / 255) * 255},${255 - (val / globalMax) * 255}`).join(' L'); - }; + const generatePaths = (data: number[]) => { + if (!data.length) return { fill: '', line: '' }; - const channels = [ - { key: 'red', color: '#FF6B6B', data: histogram.red }, - { key: 'green', color: '#6BCB77', data: histogram.green }, - { key: 'blue', color: '#4D96FF', data: histogram.blue }, - ]; + let pathPoints = ''; + const len = data.length; + for (let i = 0; i < len; i++) { + const x = (i / (len - 1)) * 255; + const y = 255 - (data[i] / globalMax) * 255; + pathPoints += (i === 0 ? '' : ' L') + `${x},${y}`; + } + + return { + fill: `M0,255 L${pathPoints} L255,255 Z`, + line: `M${pathPoints}`, + }; + }; + + return [ + { key: 'red', color: '#FF6B6B', ...generatePaths(histogram.red) }, + { key: 'green', color: '#6BCB77', ...generatePaths(histogram.green) }, + { key: 'blue', color: '#4D96FF', ...generatePaths(histogram.blue) }, + ]; + }, [histogram]); + + if (!channels) return null; return ( { className="w-full h-full overflow-visible pointer-events-none" preserveAspectRatio="none" > - {channels.map((ch) => { - if (!ch.data || ch.data.length === 0) return null; - return ( - - - - - ); - })} + {channels.map((ch) => ( + + + + + ))} ); -}; +}); const FakeHistogramLoader = () => { const canvasRef = useRef(null); diff --git a/src/components/ui/Slider.tsx b/src/components/ui/Slider.tsx index 0d04310f2..345e424ad 100644 --- a/src/components/ui/Slider.tsx +++ b/src/components/ui/Slider.tsx @@ -73,6 +73,9 @@ const Slider = ({ snapToStepRef.current = snapToStep; rangeRef.current = { min, max }; + const pendingDispatchRef = useRef(null); + const dispatchRafRef = useRef(null); + useEffect(() => { onDragStateChange(isDragging); }, [isDragging, onDragStateChange]); @@ -147,10 +150,27 @@ const Slider = ({ const snappedValue = snapToStepRef.current(accumulatedValueRef.current); setDisplayValue(snappedValue); - onChangeRef.current({ target: { value: snappedValue } }); + pendingDispatchRef.current = snappedValue; + if (dispatchRafRef.current === null) { + dispatchRafRef.current = requestAnimationFrame(() => { + if (pendingDispatchRef.current !== null) { + onChangeRef.current({ target: { value: pendingDispatchRef.current } }); + } + dispatchRafRef.current = null; + }); + } }; const handlePointerUp = () => { + // Flush any pending dispatch immediately on release + if (dispatchRafRef.current !== null) { + cancelAnimationFrame(dispatchRafRef.current); + dispatchRafRef.current = null; + } + if (pendingDispatchRef.current !== null) { + onChangeRef.current({ target: { value: pendingDispatchRef.current } }); + pendingDispatchRef.current = null; + } lastUpTime.current = Date.now(); setIsDragging(false); }; @@ -165,6 +185,10 @@ const Slider = ({ window.removeEventListener('mouseup', handlePointerUp); window.removeEventListener('touchmove', handlePointerMove); window.removeEventListener('touchend', handlePointerUp); + if (dispatchRafRef.current !== null) { + cancelAnimationFrame(dispatchRafRef.current); + dispatchRafRef.current = null; + } }; }, [isDragging]); diff --git a/src/hooks/useHistoryState.tsx b/src/hooks/useHistoryState.tsx index e71cd516a..c1485fd34 100644 --- a/src/hooks/useHistoryState.tsx +++ b/src/hooks/useHistoryState.tsx @@ -1,5 +1,14 @@ import { useState, useMemo, useCallback } from 'react'; +function shallowEqual(a: any, b: any): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((k) => a[k] === b[k]); +} + export const useHistoryState = (initialState: any) => { const [history, setHistory] = useState([initialState]); const [index, setIndex] = useState(0); @@ -8,7 +17,7 @@ export const useHistoryState = (initialState: any) => { const setState = useCallback( (newState: any) => { const resolvedState = typeof newState === 'function' ? newState(history[index]) : newState; - if (JSON.stringify(resolvedState) === JSON.stringify(history[index])) { + if (shallowEqual(resolvedState, history[index])) { return; } const newHistory = history.slice(0, index + 1); diff --git a/src/hooks/useKeyboardShortcuts.tsx b/src/hooks/useKeyboardShortcuts.tsx index 606c58204..afe6b3930 100644 --- a/src/hooks/useKeyboardShortcuts.tsx +++ b/src/hooks/useKeyboardShortcuts.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { ImageFile, Panel, SelectedImage } from '../components/ui/AppProperties'; import { BrushSettings } from '../components/ui/AppProperties'; @@ -55,60 +55,64 @@ interface KeyboardShortcutsProps { setBrushSettings: (settings: BrushSettings) => void; } -export const useKeyboardShortcuts = ({ - activeAiPatchContainerId, - activeAiSubMaskId, - activeMaskContainerId, - activeMaskId, - activeRightPanel, - osPlatform, - canRedo, - canUndo, - copiedFilePaths, - customEscapeHandler, - handleBackToLibrary, - handleCopyAdjustments, - handleDeleteAiPatch, - handleDeleteMaskContainer, - handleDeleteSelected, - handleImageSelect, - handlePasteAdjustments, - handlePasteFiles, - handleRate, - handleRightPanelSelect, - handleRotate, - handleSetColorLabel, - handleToggleFullScreen, - handleZoomChange, - isFullScreen, - isModalOpen, - isStraightenActive, - isViewLoading, - libraryActivePath, - multiSelectedPaths, - onSelectPatchContainer, - redo, - selectedImage, - setActiveAiSubMaskId, - setActiveMaskContainerId, - setActiveMaskId, - setCopiedFilePaths, - setIsStraightenActive, - setIsWaveformVisible, - setLibraryActivePath, - setMultiSelectedPaths, - setShowOriginal, - sortedImageList, - undo, - zoom, - displaySize, - baseRenderSize, - originalSize, - brushSettings, - setBrushSettings, -}: KeyboardShortcutsProps) => { +export const useKeyboardShortcuts = (props: KeyboardShortcutsProps) => { + const propsRef = useRef(props); + propsRef.current = props; + useEffect(() => { const handleKeyDown = (event: any) => { + const { + activeAiPatchContainerId, + activeAiSubMaskId, + activeMaskContainerId, + activeMaskId, + activeRightPanel, + osPlatform, + canRedo, + canUndo, + copiedFilePaths, + customEscapeHandler, + handleBackToLibrary, + handleCopyAdjustments, + handleDeleteAiPatch, + handleDeleteMaskContainer, + handleDeleteSelected, + handleImageSelect, + handlePasteAdjustments, + handlePasteFiles, + handleRate, + handleRightPanelSelect, + handleRotate, + handleSetColorLabel, + handleToggleFullScreen, + handleZoomChange, + isFullScreen, + isModalOpen, + isStraightenActive, + isViewLoading, + libraryActivePath, + multiSelectedPaths, + onSelectPatchContainer, + redo, + selectedImage, + setActiveAiSubMaskId, + setActiveMaskContainerId, + setActiveMaskId, + setCopiedFilePaths, + setIsStraightenActive, + setIsWaveformVisible, + setLibraryActivePath, + setMultiSelectedPaths, + setShowOriginal, + sortedImageList, + undo, + zoom, + displaySize, + baseRenderSize, + originalSize, + brushSettings, + setBrushSettings, + } = propsRef.current; if (isModalOpen) { return; } @@ -454,55 +458,5 @@ export const useKeyboardShortcuts = ({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [ - activeAiPatchContainerId, - activeAiSubMaskId, - activeMaskContainerId, - activeMaskId, - activeRightPanel, - osPlatform, - canRedo, - canUndo, - copiedFilePaths, - customEscapeHandler, - handleBackToLibrary, - handleCopyAdjustments, - handleDeleteAiPatch, - handleDeleteMaskContainer, - handleDeleteSelected, - handleImageSelect, - handlePasteAdjustments, - handlePasteFiles, - handleRate, - handleRightPanelSelect, - handleRotate, - handleSetColorLabel, - handleToggleFullScreen, - handleZoomChange, - isFullScreen, - isStraightenActive, - isViewLoading, - libraryActivePath, - multiSelectedPaths, - onSelectPatchContainer, - redo, - selectedImage, - setActiveAiSubMaskId, - setActiveMaskContainerId, - setActiveMaskId, - setCopiedFilePaths, - setIsStraightenActive, - setIsWaveformVisible, - setLibraryActivePath, - setMultiSelectedPaths, - setShowOriginal, - sortedImageList, - undo, - zoom, - displaySize, - baseRenderSize, - originalSize, - brushSettings, - setBrushSettings, - ]); + }, []); }; diff --git a/src/hooks/useThumbnails.tsx b/src/hooks/useThumbnails.tsx index 2aa3b7f8d..71ff1d283 100644 --- a/src/hooks/useThumbnails.tsx +++ b/src/hooks/useThumbnails.tsx @@ -12,6 +12,12 @@ export function useThumbnails() { if (!processorRef.current) { processorRef.current = setInterval(() => { + if (visiblePathsRef.current.size === 0) { + clearInterval(processorRef.current!); + processorRef.current = null; + return; + } + const pathsToRequest = Array.from(visiblePathsRef.current).filter((p) => !requestedPathsRef.current.has(p)); if (pathsToRequest.length > 0) {