Download src/hooks/use-auto-resize.ts from SaylorTwift/openhands: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/use-auto-resize.ts
- Command line
-
hf download hf://SaylorTwift/openhands/src/hooks/use-auto-resize.ts
-
curl -L -o use-auto-resize.ts https://huggingface.co/SaylorTwift/openhands/resolve/main/src/hooks/use-auto-resize.ts
11 kB
| import { useCallback, useEffect, RefObject, useRef } from "react"; | |
| import { IMessageToSend } from "#/stores/conversation-store"; | |
| import { EPS } from "#/utils/constants"; | |
| import { getStyleHeightPx, setStyleHeightPx } from "#/utils/utils"; | |
| import { focusContentEditableAtEnd } from "#/components/features/chat/utils/chat-input.utils"; | |
| import { useDragResize } from "./use-drag-resize"; | |
| // Constants | |
| const DEFAULT_MIN_HEIGHT = 20; | |
| const DEFAULT_MAX_HEIGHT = 120; | |
| const HEIGHT_INCREMENT = 20; | |
| const MANUAL_OVERSIZE_THRESHOLD = 50; | |
| // Manual height tracking utilities | |
| const useManualHeight = () => { | |
| const hasUserResizedRef = useRef(false); | |
| const manualHeightRef = useRef<number | null>(null); | |
| return { hasUserResizedRef, manualHeightRef }; | |
| }; | |
| interface UseAutoResizeOptions { | |
| minHeight?: number; | |
| maxHeight?: number; | |
| enableManualResize?: boolean; | |
| onGripDragStart?: () => void; | |
| onGripDragEnd?: () => void; | |
| onHeightChange?: (height: number) => void; // New callback for height changes | |
| value?: IMessageToSend; | |
| /** Called once `value` has been applied to the element (one-shot consume). */ | |
| onValueApplied?: () => void; | |
| } | |
| interface UseAutoResizeReturn { | |
| smartResize: () => void; | |
| handleGripMouseDown: (e: React.MouseEvent) => void; | |
| handleGripTouchStart: (e: React.TouchEvent) => void; | |
| increaseHeightForEmptyContent: () => void; | |
| resetManualResize: () => void; | |
| } | |
| // Height management utilities | |
| interface HeightConstraints { | |
| minHeight: number; | |
| maxHeight: number; | |
| } | |
| interface HeightMeasurements { | |
| currentHeight: number; | |
| currentStyleHeight: number; | |
| contentHeight: number; | |
| } | |
| interface ResizeStrategy { | |
| finalHeight: number; | |
| overflowY: "hidden" | "auto"; | |
| } | |
| const applyHeightToElement = ( | |
| element: HTMLElement, | |
| height: number, | |
| constraints: HeightConstraints, | |
| ): number => { | |
| const { minHeight, maxHeight } = constraints; | |
| const finalHeight = Math.max(minHeight, Math.min(height, maxHeight)); | |
| element.style.setProperty("height", `${finalHeight}px`); | |
| element.style.setProperty( | |
| "overflow-y", | |
| finalHeight >= maxHeight ? "auto" : "hidden", | |
| ); | |
| return finalHeight; | |
| }; | |
| const isManuallyOversized = ( | |
| currentHeight: number, | |
| contentHeight: number, | |
| threshold = MANUAL_OVERSIZE_THRESHOLD, | |
| ): boolean => currentHeight > contentHeight + threshold; | |
| const measureElementHeights = ( | |
| element: HTMLElement, | |
| minHeight: number, | |
| ): HeightMeasurements => { | |
| // Use the previous explicit style height as the "current" for restore, not offsetHeight | |
| const currentStyleHeight = getStyleHeightPx(element, minHeight); | |
| const currentHeight = currentStyleHeight; | |
| // Temporarily reset to measure content | |
| element.style.setProperty("height", "auto"); | |
| const contentHeight = element.scrollHeight; | |
| // Restore height | |
| setStyleHeightPx(element, currentStyleHeight); | |
| return { | |
| currentHeight, | |
| currentStyleHeight, | |
| contentHeight, | |
| }; | |
| }; | |
| const applyResizeStrategy = ( | |
| element: HTMLElement, | |
| strategy: ResizeStrategy, | |
| ): void => { | |
| const { finalHeight, overflowY } = strategy; | |
| const elementRef = element; | |
| elementRef.style.height = `${finalHeight}px`; | |
| elementRef.style.overflowY = overflowY; | |
| }; | |
| const executeHeightCallback = ( | |
| height: number, | |
| onHeightChange?: (height: number) => void, | |
| ): void => { | |
| if (onHeightChange) { | |
| onHeightChange(height); | |
| } | |
| }; | |
| // DOM manipulation utilities | |
| export const useAutoResize = ( | |
| elementRef: RefObject<HTMLElement | null>, | |
| options: UseAutoResizeOptions = {}, | |
| ): UseAutoResizeReturn => { | |
| const pendingSmartRef = useRef<number | null>(null); | |
| const { | |
| minHeight = DEFAULT_MIN_HEIGHT, | |
| maxHeight = DEFAULT_MAX_HEIGHT, | |
| enableManualResize = false, | |
| value, | |
| onValueApplied, | |
| onGripDragStart, | |
| onGripDragEnd, | |
| onHeightChange, | |
| } = options; | |
| const constraints: HeightConstraints = { minHeight, maxHeight }; | |
| const { hasUserResizedRef, manualHeightRef } = useManualHeight(); | |
| const resetManualResize = () => { | |
| hasUserResizedRef.current = false; | |
| manualHeightRef.current = null; | |
| }; | |
| // Wrap onHeightChange to track manual height during drag | |
| const handleExternalHeightChange = useCallback( | |
| (elementHeight: number) => { | |
| onHeightChange?.(elementHeight); | |
| if (hasUserResizedRef.current) { | |
| manualHeightRef.current = elementHeight; | |
| } | |
| }, | |
| [onHeightChange], | |
| ); | |
| // Handle drag start - set manual mode flag | |
| const handleDragStart = useCallback(() => { | |
| hasUserResizedRef.current = true; | |
| onGripDragStart?.(); | |
| }, [onGripDragStart]); | |
| // Handle drag end - clear manual mode if at minimum height | |
| const handleDragEnd = useCallback(() => { | |
| const textareaElement = elementRef.current; | |
| if (textareaElement) { | |
| const currentHeight = getStyleHeightPx(textareaElement, minHeight); | |
| if (Math.abs(currentHeight - minHeight) <= EPS) { | |
| hasUserResizedRef.current = false; | |
| manualHeightRef.current = null; | |
| } | |
| } | |
| onGripDragEnd?.(); | |
| }, [minHeight, onGripDragEnd]); | |
| // Use the drag resize hook for manual resizing functionality | |
| const { handleGripMouseDown, handleGripTouchStart } = useDragResize({ | |
| elementRef, | |
| minHeight, | |
| maxHeight, | |
| onGripDragStart: enableManualResize ? handleDragStart : undefined, | |
| onGripDragEnd: enableManualResize ? handleDragEnd : undefined, | |
| onHeightChange: handleExternalHeightChange, | |
| }); | |
| // Handle content that fits within current height | |
| const handleContentFitsInCurrentHeight = useCallback( | |
| ( | |
| element: HTMLElement, | |
| currentHeight: number, | |
| contentHeight: number, | |
| ): void => { | |
| // If user manually resized and we're above min height, preserve their chosen height | |
| if (hasUserResizedRef.current && currentHeight > minHeight) { | |
| applyResizeStrategy(element, { | |
| finalHeight: currentHeight, | |
| overflowY: "hidden", | |
| }); | |
| executeHeightCallback(currentHeight, onHeightChange); | |
| return; | |
| } | |
| // Otherwise allow shrinking towards content (respect minHeight) | |
| const finalHeight = Math.max(contentHeight, minHeight); | |
| applyResizeStrategy(element, { | |
| finalHeight, | |
| overflowY: "hidden", | |
| }); | |
| executeHeightCallback(finalHeight, onHeightChange); | |
| }, | |
| [minHeight, onHeightChange], | |
| ); | |
| // Handle content that exceeds current height but within max height | |
| const handleContentExceedsCurrentHeight = useCallback( | |
| ( | |
| element: HTMLElement, | |
| currentHeight: number, | |
| contentHeight: number, | |
| ): void => { | |
| // Grow unless the element is manually oversized beyond content significantly | |
| if (!isManuallyOversized(currentHeight, contentHeight)) { | |
| const finalHeight = Math.max(contentHeight, minHeight); | |
| applyResizeStrategy(element, { | |
| finalHeight, | |
| overflowY: "hidden", | |
| }); | |
| executeHeightCallback(finalHeight, onHeightChange); | |
| return; | |
| } | |
| // Keep manual height and allow scrolling as needed | |
| applyResizeStrategy(element, { | |
| finalHeight: currentHeight, | |
| overflowY: "auto", | |
| }); | |
| executeHeightCallback(currentHeight, onHeightChange); | |
| }, | |
| [minHeight, onHeightChange], | |
| ); | |
| // Handle content that exceeds max height | |
| const handleContentExceedsMaxHeight = useCallback( | |
| (element: HTMLElement) => { | |
| applyResizeStrategy(element, { | |
| finalHeight: maxHeight, | |
| overflowY: "auto", | |
| }); | |
| executeHeightCallback(maxHeight, onHeightChange); | |
| }, | |
| [maxHeight, onHeightChange], | |
| ); | |
| // Debounced smartResize body | |
| const smartResizeBody = useCallback(() => { | |
| const element = elementRef.current; | |
| if (!element) return; | |
| const textIsEmpty = (element.textContent ?? "").trim().length === 0; | |
| // If empty content and we have a manual height above min, preserve it | |
| if ( | |
| textIsEmpty && | |
| hasUserResizedRef.current && | |
| manualHeightRef.current && | |
| manualHeightRef.current > minHeight + EPS | |
| ) { | |
| setStyleHeightPx(element, manualHeightRef.current); | |
| element.style.overflowY = "hidden"; | |
| executeHeightCallback(manualHeightRef.current, onHeightChange); | |
| return; | |
| } | |
| // Measure element heights | |
| const measurements = measureElementHeights(element, minHeight); | |
| const { currentHeight, contentHeight } = measurements; | |
| // If content fits within current height | |
| if (contentHeight <= currentHeight) { | |
| handleContentFitsInCurrentHeight(element, currentHeight, contentHeight); | |
| return; | |
| } | |
| // If content exceeds current height but within max | |
| if (contentHeight <= maxHeight) { | |
| handleContentExceedsCurrentHeight(element, currentHeight, contentHeight); | |
| return; | |
| } | |
| // Content exceeds max height | |
| handleContentExceedsMaxHeight(element); | |
| }, [ | |
| elementRef, | |
| minHeight, | |
| maxHeight, | |
| onHeightChange, | |
| handleContentFitsInCurrentHeight, | |
| handleContentExceedsCurrentHeight, | |
| handleContentExceedsMaxHeight, | |
| ]); | |
| // rAF-debounced smartResize wrapper to collapse bursts | |
| const smartResize = useCallback(() => { | |
| if (pendingSmartRef.current) cancelAnimationFrame(pendingSmartRef.current); | |
| pendingSmartRef.current = requestAnimationFrame(() => { | |
| pendingSmartRef.current = null; | |
| smartResizeBody(); | |
| }); | |
| }, [smartResizeBody]); | |
| // Function to increase height when content is empty | |
| const increaseHeightForEmptyContent = () => { | |
| const element = elementRef.current; | |
| if (!element) return; | |
| const currentHeight = getStyleHeightPx(element, minHeight); | |
| const newHeight = Math.min(currentHeight + HEIGHT_INCREMENT, maxHeight); | |
| if (newHeight > currentHeight) { | |
| const finalHeight = applyHeightToElement(element, newHeight, constraints); | |
| // Execute height change callback | |
| executeHeightCallback(finalHeight, onHeightChange); | |
| // Set manual mode for Shift+Enter height increases | |
| hasUserResizedRef.current = true; | |
| manualHeightRef.current = finalHeight; | |
| } | |
| }; | |
| // Update content and resize when value prop changes | |
| useEffect(() => { | |
| if (value === undefined) { | |
| return undefined; | |
| } | |
| let rafId = 0; | |
| const applyValue = () => { | |
| const element = elementRef.current; | |
| if (!element) { | |
| return false; | |
| } | |
| element.textContent = value.text; | |
| smartResize(); | |
| focusContentEditableAtEnd(element); | |
| onValueApplied?.(); // one-shot: let the caller clear the value | |
| return true; | |
| }; | |
| if (!applyValue()) { | |
| rafId = requestAnimationFrame(() => { | |
| applyValue(); | |
| }); | |
| } | |
| return () => { | |
| if (rafId) { | |
| cancelAnimationFrame(rafId); | |
| } | |
| }; | |
| }, [value, smartResize, onValueApplied]); | |
| // Initialize auto-resize on mount | |
| useEffect(() => { | |
| smartResize(); | |
| }, [smartResize]); | |
| return { | |
| smartResize, | |
| handleGripMouseDown, | |
| handleGripTouchStart, | |
| increaseHeightForEmptyContent, | |
| resetManualResize, | |
| }; | |
| }; | |