openhands / src /hooks /use-auto-resize.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
63522a5 verified
Raw History Blame Contribute Delete
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,
};
};