| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- export const DEFAULT_RESIZABLE_INPUT_HEIGHT = 44
- const INPUT_BOTTOM_RESERVED = 80
- const MIN_MESSAGE_AREA_MIN = 80
- const MIN_MESSAGE_AREA_RATIO = 0.15
- const MIN_USABLE_CONTAINER_HEIGHT = DEFAULT_RESIZABLE_INPUT_HEIGHT + MIN_MESSAGE_AREA_MIN + 80
- export interface ResizableInputBounds {
- minHeight: number
- maxHeight: number
- }
- export interface ResizeContext {
- startRootTop: number
- startRootHeight: number
- startInputHeight: number
- viewportHeight: number
- containerHeight: number
- fixedOverhead: number
- }
- function computeMinMessageAreaHeight(containerHeight: number): number {
- return Math.max(MIN_MESSAGE_AREA_MIN, Math.floor(containerHeight * MIN_MESSAGE_AREA_RATIO))
- }
- function getEffectiveRootRect(rootEl: HTMLDivElement): DOMRect {
- const rootRect = rootEl.getBoundingClientRect()
- if (rootRect.height > 0) return rootRect
- const parentRect = rootEl.parentElement?.getBoundingClientRect()
- return parentRect && parentRect.height > 0 && parentRect.height < MIN_USABLE_CONTAINER_HEIGHT
- ? parentRect
- : rootRect
- }
- export function createResizeContext(rootEl: HTMLDivElement | null, currentInputHeight: number): ResizeContext | null {
- if (!rootEl) return null
- const rootRect = getEffectiveRootRect(rootEl)
- const containerEl = findChatContainer(rootEl)
- const containerRect = containerEl?.getBoundingClientRect()
- const containerHeight = containerRect?.height ?? window.innerHeight
- const textareaEl = rootEl.querySelector("textarea")
- const textareaRect = textareaEl?.getBoundingClientRect()
- const actualTextareaHeight = textareaRect && textareaRect.height > 0 ? textareaRect.height : currentInputHeight
- const chatInputOverhead = Math.max(0, rootRect.height - actualTextareaHeight)
- const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
- return {
- startRootTop: rootRect.top,
- startRootHeight: rootRect.height,
- startInputHeight: currentInputHeight,
- viewportHeight: window.innerHeight,
- containerHeight,
- fixedOverhead: chatInputOverhead + siblingOverhead,
- }
- }
- export function findChatContainer(root: HTMLDivElement): HTMLElement | null {
- let current: HTMLElement | null = root.parentElement
- let fallback: HTMLElement | null = null
- let sizeFallback: HTMLElement | null = null
- for (let i = 0; i < 8 && current; i++) {
- const tag = current.tagName
- if (tag === "BODY" || tag === "HTML") break
- const rect = current.getBoundingClientRect()
- const hasUsableHeight = rect.height >= MIN_USABLE_CONTAINER_HEIGHT
- if (!sizeFallback && hasUsableHeight) {
- sizeFallback = current
- }
- const style = window.getComputedStyle(current)
- const isFlexColumn = style.display === "flex" && style.flexDirection === "column"
- if (isFlexColumn) {
- const hasConstrainedHeight = style.height !== "auto" && style.height !== ""
- const hasOverflowClip = style.overflowY === "hidden" || style.overflowY === "auto" || style.overflowY === "scroll"
- if ((hasConstrainedHeight || hasOverflowClip) && hasUsableHeight) {
- return current
- }
- if (!fallback && hasUsableHeight) {
- fallback = current
- }
- }
- current = current.parentElement
- }
- return fallback ?? sizeFallback ?? root.parentElement
- }
- /**
- * 计算 ChatInput 根 div 之前的所有非 flex-grow 兄弟元素总高度。
- * 用于在 maxHeight 计算中扣除 Header、Section 按钮区等外层固定开销,
- * 防止 textarea 被允许拉高到超出容器实际可用空间。
- */
- function computeSiblingOverhead(rootEl: HTMLDivElement, container: HTMLElement | null): number {
- if (!container) return 0
- let overhead = 0
- let found = false
- for (const child of Array.from(container.children)) {
- if (child === rootEl || (child as HTMLElement).contains(rootEl)) {
- found = true
- break
- }
- const style = window.getComputedStyle(child as HTMLElement)
- const flexGrow = parseFloat(style.flexGrow || "0")
- // 跳过 flex-grow > 0 的元素(如 messages 区域 flex-1,会自适应)
- if (flexGrow > 0) continue
- const rect = (child as HTMLElement).getBoundingClientRect()
- overhead += rect.height
- }
- return found ? overhead : 0
- }
- export function resolveMaxHeightFromContext(ctx: ResizeContext, nextHeight: number): number {
- const predictedRootTop = ctx.startRootTop - (nextHeight - ctx.startInputHeight)
- const viewportAvailable = ctx.viewportHeight - predictedRootTop - INPUT_BOTTOM_RESERVED
- const minMessageArea = computeMinMessageAreaHeight(ctx.containerHeight)
- const containerAvailable = ctx.containerHeight - minMessageArea - ctx.fixedOverhead
- const effectiveMax = Math.min(
- Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(viewportAvailable)),
- Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerAvailable)),
- )
- return effectiveMax
- }
- export function getResizeBoundsForElement(rootEl: HTMLDivElement | null): ResizableInputBounds {
- if (!rootEl) {
- return { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight: 200 }
- }
- const containerEl = findChatContainer(rootEl)
- const containerRect = containerEl?.getBoundingClientRect()
- const containerHeight = containerRect?.height ?? window.innerHeight
- const rootRect = getEffectiveRootRect(rootEl)
- const textareaEl = rootEl.querySelector("textarea")
- const measuredTextareaHeight = textareaEl?.getBoundingClientRect().height ?? 0
- const textareaHeight = measuredTextareaHeight > 0 ? measuredTextareaHeight : DEFAULT_RESIZABLE_INPUT_HEIGHT
- const chatInputOverhead = Math.max(0, rootRect.height - textareaHeight)
- const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
- const minMessageArea = computeMinMessageAreaHeight(containerHeight)
- const containerMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerHeight - minMessageArea - chatInputOverhead - siblingOverhead))
- const viewportMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(window.innerHeight - rootRect.top - INPUT_BOTTOM_RESERVED))
- return {
- minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT,
- maxHeight: Math.min(containerMax, viewportMax),
- }
- }
- export function clampResizableInputHeight(
- nextHeight: number,
- bounds: ResizableInputBounds,
- ): number {
- const minHeight = Math.max(1, Math.floor(bounds.minHeight))
- const maxHeight = Math.max(minHeight, Math.floor(bounds.maxHeight))
- if (Number.isNaN(nextHeight)) return minHeight
- if (nextHeight === Number.POSITIVE_INFINITY) return maxHeight
- if (nextHeight === Number.NEGATIVE_INFINITY) return minHeight
- return Math.min(maxHeight, Math.max(minHeight, Math.round(nextHeight)))
- }
- export function isHeightAtMax(currentHeight: number, bounds: ResizableInputBounds): boolean {
- const maxHeight = Math.max(bounds.minHeight, Math.floor(bounds.maxHeight))
- return currentHeight >= maxHeight - 1
- }
- export function isHeightAtMin(currentHeight: number, bounds: ResizableInputBounds): boolean {
- return currentHeight <= bounds.minHeight + 1
- }
|