feat: add mouse wheel input support for slider controls#38
feat: add mouse wheel input support for slider controls#38unsafe0x0 wants to merge 2 commits intoKartikLabhshetwar:mainfrom
Conversation
|
@unsafe0x0 is attempting to deploy a commit to the Kartik Labhshetwar's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
preview: 2025-12-10.19-51-05.mp4 |
There was a problem hiding this comment.
Pull request overview
This PR introduces mouse wheel input support for slider controls throughout the editor by implementing a reusable useWheelInput hook. The hook enables users to adjust numeric values by scrolling the mouse wheel over slider controls, with support for modifier keys (Shift for 10x multiplier, Ctrl/Cmd for 0.1x multiplier). The implementation is systematically applied across 9 component files, covering text overlays, image overlays, mockups, borders, shadows, 3D perspective transforms, and background effects.
Key Changes:
- Created a new
useWheelInputcustom hook that attaches wheel event listeners to container elements and calculates adjusted values based on wheel direction and modifier keys - Applied the hook to 37 slider controls across the editor, attaching refs to slider container divs to enable wheel input
- Maintained existing slider functionality while adding wheel input as an enhancement
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
hooks/useWheelInput.ts |
New custom hook implementing wheel event handling with step calculation, precision rounding, and min/max clamping |
components/text-overlay/text-overlay-controls.tsx |
Added wheel input support for 7 text overlay sliders (font size, opacity, shadow properties, position) |
components/overlays/overlay-controls.tsx |
Added wheel input support for 5 image overlay sliders (size, rotation, opacity, X/Y position) |
components/mockups/MockupControls.tsx |
Added wheel input support for 5 mockup sliders (size, rotation, opacity, X/Y position) |
components/editor/style-tabs.tsx |
Added wheel input support for 3 image style sliders (border radius, scale, opacity) |
components/editor/editor-right-panel.tsx |
Added wheel input support for 2 background sliders (opacity, border radius) |
components/controls/ShadowControls.tsx |
Added wheel input support for 4 shadow sliders (blur, offsetX, offsetY, spread) |
components/controls/Perspective3DControls.tsx |
Added wheel input support for 6 perspective transform sliders (perspective, rotations, translations) |
components/controls/BorderControls.tsx |
Added wheel input support for 2 border sliders (width, padding) |
components/controls/BackgroundEffects.tsx |
Added wheel input support for 2 background effect sliders (blur, noise) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const { ref: fontSizeRef } = useWheelInput({ | ||
| value: selectedOverlay?.fontSize ?? 24, | ||
| onChange: (val) => handleUpdateFontSize([val]), | ||
| min: 12, | ||
| max: 150, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: opacityRef } = useWheelInput({ | ||
| value: selectedOverlay?.opacity ?? 1, | ||
| onChange: (val) => handleUpdateOpacity([val]), | ||
| min: 0, | ||
| max: 1, | ||
| step: 0.01, | ||
| }); | ||
|
|
||
| const { ref: shadowBlurRef } = useWheelInput({ | ||
| value: selectedOverlay?.textShadow.blur ?? 0, | ||
| onChange: (val) => handleUpdateTextShadow({ blur: val }), | ||
| min: 0, | ||
| max: 20, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: shadowOffsetXRef } = useWheelInput({ | ||
| value: selectedOverlay?.textShadow.offsetX ?? 0, | ||
| onChange: (val) => handleUpdateTextShadow({ offsetX: val }), | ||
| min: -20, | ||
| max: 20, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: shadowOffsetYRef } = useWheelInput({ | ||
| value: selectedOverlay?.textShadow.offsetY ?? 0, | ||
| onChange: (val) => handleUpdateTextShadow({ offsetY: val }), | ||
| min: -20, | ||
| max: 20, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: positionXRef } = useWheelInput({ | ||
| value: selectedOverlay?.position.x ?? 50, | ||
| onChange: (val) => handleUpdatePosition('x', [val]), | ||
| min: 0, | ||
| max: 100, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: positionYRef } = useWheelInput({ | ||
| value: selectedOverlay?.position.y ?? 50, | ||
| onChange: (val) => handleUpdatePosition('y', [val]), | ||
| min: 0, | ||
| max: 100, | ||
| step: 1, | ||
| }); |
There was a problem hiding this comment.
The useWheelInput hooks are initialized outside of any conditional check, but they use selectedOverlay values which can be undefined when no overlay is selected. This could lead to the hooks being called with undefined values. While the default values (using ??) handle this, the hooks will still trigger onChange callbacks with potentially incorrect values when the wheel is scrolled without a selection.
Consider wrapping these hook calls in a conditional or moving them inside the conditional render block that checks for selectedOverlay, or add early returns in the onChange handlers to prevent updates when no overlay is selected.
|
|
||
| const { ref: rotationRef } = useWheelInput({ | ||
| value: selectedOverlay?.rotation ?? 0, | ||
| onChange: (val) => handleUpdateRotation([val]), | ||
| min: 0, | ||
| max: 360, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: opacityRef } = useWheelInput({ | ||
| value: selectedOverlay?.opacity ?? 1, | ||
| onChange: (val) => handleUpdateOpacity([val]), | ||
| min: 0, | ||
| max: 1, | ||
| step: 0.01, | ||
| }); | ||
|
|
||
| const { ref: posXRef } = useWheelInput({ | ||
| value: selectedOverlay?.position.x ?? 0, | ||
| onChange: (val) => handleUpdatePosition('x', [val]), | ||
| min: 0, | ||
| max: 800, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: posYRef } = useWheelInput({ | ||
| value: selectedOverlay?.position.y ?? 0, | ||
| onChange: (val) => handleUpdatePosition('y', [val]), | ||
| min: 0, | ||
| max: 600, | ||
| step: 1, | ||
| }); | ||
|
|
||
|
|
There was a problem hiding this comment.
The useWheelInput hooks are initialized with selectedOverlay values which can be undefined when no overlay is selected. While the default values (using ??) prevent crashes, if a user scrolls the wheel over these controls when no overlay is selected, the onChange handlers will be called but won't update anything due to the guards in handleUpdateSize, etc. This is fine functionally, but it's processing unnecessary events.
The handlers already check for selectedOverlay, so this is safe but not optimal. Consider documenting this behavior or conditionally rendering the refs only when an overlay is selected.
| const { ref: rotationRef } = useWheelInput({ | |
| value: selectedOverlay?.rotation ?? 0, | |
| onChange: (val) => handleUpdateRotation([val]), | |
| min: 0, | |
| max: 360, | |
| step: 1, | |
| }); | |
| const { ref: opacityRef } = useWheelInput({ | |
| value: selectedOverlay?.opacity ?? 1, | |
| onChange: (val) => handleUpdateOpacity([val]), | |
| min: 0, | |
| max: 1, | |
| step: 0.01, | |
| }); | |
| const { ref: posXRef } = useWheelInput({ | |
| value: selectedOverlay?.position.x ?? 0, | |
| onChange: (val) => handleUpdatePosition('x', [val]), | |
| min: 0, | |
| max: 800, | |
| step: 1, | |
| }); | |
| const { ref: posYRef } = useWheelInput({ | |
| value: selectedOverlay?.position.y ?? 0, | |
| onChange: (val) => handleUpdatePosition('y', [val]), | |
| min: 0, | |
| max: 600, | |
| step: 1, | |
| }); | |
| // Only initialize wheel input hooks if an overlay is selected | |
| let rotationRef, opacityRef, posXRef, posYRef; | |
| if (selectedOverlay) { | |
| ({ ref: rotationRef } = useWheelInput({ | |
| value: selectedOverlay.rotation, | |
| onChange: (val) => handleUpdateRotation([val]), | |
| min: 0, | |
| max: 360, | |
| step: 1, | |
| })); | |
| ({ ref: opacityRef } = useWheelInput({ | |
| value: selectedOverlay.opacity, | |
| onChange: (val) => handleUpdateOpacity([val]), | |
| min: 0, | |
| max: 1, | |
| step: 0.01, | |
| })); | |
| ({ ref: posXRef } = useWheelInput({ | |
| value: selectedOverlay.position.x, | |
| onChange: (val) => handleUpdatePosition('x', [val]), | |
| min: 0, | |
| max: 800, | |
| step: 1, | |
| })); | |
| ({ ref: posYRef } = useWheelInput({ | |
| value: selectedOverlay.position.y, | |
| onChange: (val) => handleUpdatePosition('y', [val]), | |
| min: 0, | |
| max: 600, | |
| step: 1, | |
| })); | |
| } |
| const { ref: sizeRef } = useWheelInput({ | ||
| value: selectedMockup?.size ?? 600, | ||
| onChange: (val) => handleUpdateSize([val]), | ||
| min: 200, | ||
| max: 1200, | ||
| step: 10, | ||
| }); | ||
|
|
||
| const { ref: rotationRef } = useWheelInput({ | ||
| value: selectedMockup?.rotation ?? 0, | ||
| onChange: (val) => handleUpdateRotation([val]), | ||
| min: 0, | ||
| max: 360, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: opacityRef } = useWheelInput({ | ||
| value: selectedMockup?.opacity ?? 1, | ||
| onChange: (val) => handleUpdateOpacity([val]), | ||
| min: 0, | ||
| max: 1, | ||
| step: 0.01, | ||
| }); | ||
|
|
||
| const { ref: posXRef } = useWheelInput({ | ||
| value: selectedMockup?.position.x ?? 0, | ||
| onChange: (val) => handleUpdatePosition('x', [val]), | ||
| min: 0, | ||
| max: 1600, | ||
| step: 1, | ||
| }); | ||
|
|
||
| const { ref: posYRef } = useWheelInput({ | ||
| value: selectedMockup?.position.y ?? 0, | ||
| onChange: (val) => handleUpdatePosition('y', [val]), | ||
| min: 0, | ||
| max: 1000, | ||
| step: 1, | ||
| }); |
There was a problem hiding this comment.
The useWheelInput hooks are initialized with selectedMockup values which can be undefined when no mockup is selected. While the default values (using ??) prevent crashes, if a user scrolls the wheel over these controls when no mockup is selected, the onChange handlers will be called but won't update anything due to the guards in handleUpdateSize, etc. This is safe functionally but processes unnecessary events.
Consider documenting this behavior or conditionally rendering the refs only when a mockup is selected.
| interface UseWheelInputOptions { | ||
| value: number; | ||
| onChange: (value: number) => void; | ||
| min?: number; | ||
| max?: number; | ||
| step?: number; | ||
| } |
There was a problem hiding this comment.
The hook and its interface lack JSDoc comments explaining its purpose, parameters, and return value. Consider adding documentation, especially to describe the modifier key behavior (Shift for 10x, Ctrl/Cmd for 0.1x) which is important for users to know:
/**
* Hook that enables mouse wheel input for numeric value adjustments.
*
* @param value - Current numeric value
* @param onChange - Callback function when value changes
* @param min - Minimum allowed value (default: -Infinity)
* @param max - Maximum allowed value (default: Infinity)
* @param step - Base increment/decrement step (default: 1)
*
* @returns Object with a ref to attach to the container element
*
* @remarks
* Supports modifier keys:
* - Shift: 10x step multiplier
* - Ctrl/Cmd: 0.1x step multiplier
*/|
|
||
| const handleWheel = useCallback( | ||
| (e: WheelEvent) => { | ||
| e.preventDefault(); |
There was a problem hiding this comment.
Calling e.preventDefault() unconditionally blocks the browser's native scroll behavior when the user scrolls over slider controls. This can be disruptive to user experience, especially when users are trying to scroll the page and their cursor happens to be over a slider.
Consider adding a condition to only prevent default when the element is focused or when a modifier key is held, allowing normal scrolling otherwise. For example:
if (document.activeElement === e.target || e.shiftKey || e.ctrlKey || e.metaKey) {
e.preventDefault();
}| e.preventDefault(); | |
| if ( | |
| document.activeElement === e.currentTarget || | |
| e.shiftKey || | |
| e.ctrlKey || | |
| e.metaKey | |
| ) { | |
| e.preventDefault(); | |
| } |
| const handleWheel = useCallback( | ||
| (e: WheelEvent) => { | ||
| e.preventDefault(); | ||
| const delta = e.deltaY > 0 ? -step : step; | ||
| const multiplier = e.shiftKey ? 10 : e.ctrlKey || e.metaKey ? 0.1 : 1; | ||
| const rawValue = value + delta * multiplier; | ||
| const precision = step < 1 ? Math.ceil(-Math.log10(step)) : 0; | ||
| const newValue = Math.min( | ||
| max, | ||
| Math.max(min, parseFloat(rawValue.toFixed(precision))), | ||
| ); | ||
| onChange(newValue); | ||
| }, | ||
| [value, onChange, min, max, step], | ||
| ); |
There was a problem hiding this comment.
The handleWheel callback depends on the value prop, causing it to be recreated on every value change. This means the event listener is removed and re-added on every slider adjustment, which is inefficient. Consider using a ref to store the latest value instead:
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
}, [value]);
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -step : step;
const multiplier = e.shiftKey ? 10 : e.ctrlKey || e.metaKey ? 0.1 : 1;
const rawValue = valueRef.current + delta * multiplier;
// ... rest of the logic
},
[onChange, min, max, step], // value removed from dependencies
);| const delta = e.deltaY > 0 ? -step : step; | ||
| const multiplier = e.shiftKey ? 10 : e.ctrlKey || e.metaKey ? 0.1 : 1; | ||
| const rawValue = value + delta * multiplier; | ||
| const precision = step < 1 ? Math.ceil(-Math.log10(step)) : 0; |
There was a problem hiding this comment.
The precision calculation using Math.ceil(-Math.log10(step)) will produce incorrect results for steps like 0.01 when the step is represented imprecisely in floating point (e.g., Math.log10(0.01) might not be exactly -2). Additionally, for steps like 10, this produces precision of -1, which is incorrect. Consider a more robust approach:
const precision = step < 1 ? Math.abs(step.toString().split('.')[1]?.length ?? 0) : 0;Or use a safer calculation:
const precision = step < 1 ? Math.max(0, -Math.floor(Math.log10(step))) : 0;| const precision = step < 1 ? Math.ceil(-Math.log10(step)) : 0; | |
| const precision = step < 1 ? Math.abs(step.toString().split('.')[1]?.length ?? 0) : 0; |
|
hi @unsafe0x0 can you please fix the suggestion from copilot. |
|
@KartikLabhshetwar fixed the suggestion from copilot. |
added mouse wheel input to editor slider controls