chat-input-resize.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. export const DEFAULT_RESIZABLE_INPUT_HEIGHT = 44
  2. const INPUT_BOTTOM_RESERVED = 80
  3. const MIN_MESSAGE_AREA_MIN = 80
  4. const MIN_MESSAGE_AREA_RATIO = 0.15
  5. const MIN_USABLE_CONTAINER_HEIGHT = DEFAULT_RESIZABLE_INPUT_HEIGHT + MIN_MESSAGE_AREA_MIN + 80
  6. export interface ResizableInputBounds {
  7. minHeight: number
  8. maxHeight: number
  9. }
  10. export interface ResizeContext {
  11. startRootTop: number
  12. startRootHeight: number
  13. startInputHeight: number
  14. viewportHeight: number
  15. containerHeight: number
  16. fixedOverhead: number
  17. }
  18. function computeMinMessageAreaHeight(containerHeight: number): number {
  19. return Math.max(MIN_MESSAGE_AREA_MIN, Math.floor(containerHeight * MIN_MESSAGE_AREA_RATIO))
  20. }
  21. function getEffectiveRootRect(rootEl: HTMLDivElement): DOMRect {
  22. const rootRect = rootEl.getBoundingClientRect()
  23. if (rootRect.height > 0) return rootRect
  24. const parentRect = rootEl.parentElement?.getBoundingClientRect()
  25. return parentRect && parentRect.height > 0 && parentRect.height < MIN_USABLE_CONTAINER_HEIGHT
  26. ? parentRect
  27. : rootRect
  28. }
  29. export function createResizeContext(rootEl: HTMLDivElement | null, currentInputHeight: number): ResizeContext | null {
  30. if (!rootEl) return null
  31. const rootRect = getEffectiveRootRect(rootEl)
  32. const containerEl = findChatContainer(rootEl)
  33. const containerRect = containerEl?.getBoundingClientRect()
  34. const containerHeight = containerRect?.height ?? window.innerHeight
  35. const textareaEl = rootEl.querySelector("textarea")
  36. const textareaRect = textareaEl?.getBoundingClientRect()
  37. const actualTextareaHeight = textareaRect && textareaRect.height > 0 ? textareaRect.height : currentInputHeight
  38. const chatInputOverhead = Math.max(0, rootRect.height - actualTextareaHeight)
  39. const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
  40. return {
  41. startRootTop: rootRect.top,
  42. startRootHeight: rootRect.height,
  43. startInputHeight: currentInputHeight,
  44. viewportHeight: window.innerHeight,
  45. containerHeight,
  46. fixedOverhead: chatInputOverhead + siblingOverhead,
  47. }
  48. }
  49. export function findChatContainer(root: HTMLDivElement): HTMLElement | null {
  50. let current: HTMLElement | null = root.parentElement
  51. let fallback: HTMLElement | null = null
  52. let sizeFallback: HTMLElement | null = null
  53. for (let i = 0; i < 8 && current; i++) {
  54. const tag = current.tagName
  55. if (tag === "BODY" || tag === "HTML") break
  56. const rect = current.getBoundingClientRect()
  57. const hasUsableHeight = rect.height >= MIN_USABLE_CONTAINER_HEIGHT
  58. if (!sizeFallback && hasUsableHeight) {
  59. sizeFallback = current
  60. }
  61. const style = window.getComputedStyle(current)
  62. const isFlexColumn = style.display === "flex" && style.flexDirection === "column"
  63. if (isFlexColumn) {
  64. const hasConstrainedHeight = style.height !== "auto" && style.height !== ""
  65. const hasOverflowClip = style.overflowY === "hidden" || style.overflowY === "auto" || style.overflowY === "scroll"
  66. if ((hasConstrainedHeight || hasOverflowClip) && hasUsableHeight) {
  67. return current
  68. }
  69. if (!fallback && hasUsableHeight) {
  70. fallback = current
  71. }
  72. }
  73. current = current.parentElement
  74. }
  75. return fallback ?? sizeFallback ?? root.parentElement
  76. }
  77. /**
  78. * 计算 ChatInput 根 div 之前的所有非 flex-grow 兄弟元素总高度。
  79. * 用于在 maxHeight 计算中扣除 Header、Section 按钮区等外层固定开销,
  80. * 防止 textarea 被允许拉高到超出容器实际可用空间。
  81. */
  82. function computeSiblingOverhead(rootEl: HTMLDivElement, container: HTMLElement | null): number {
  83. if (!container) return 0
  84. let overhead = 0
  85. let found = false
  86. for (const child of Array.from(container.children)) {
  87. if (child === rootEl || (child as HTMLElement).contains(rootEl)) {
  88. found = true
  89. break
  90. }
  91. const style = window.getComputedStyle(child as HTMLElement)
  92. const flexGrow = parseFloat(style.flexGrow || "0")
  93. // 跳过 flex-grow > 0 的元素(如 messages 区域 flex-1,会自适应)
  94. if (flexGrow > 0) continue
  95. const rect = (child as HTMLElement).getBoundingClientRect()
  96. overhead += rect.height
  97. }
  98. return found ? overhead : 0
  99. }
  100. export function resolveMaxHeightFromContext(ctx: ResizeContext, nextHeight: number): number {
  101. const predictedRootTop = ctx.startRootTop - (nextHeight - ctx.startInputHeight)
  102. const viewportAvailable = ctx.viewportHeight - predictedRootTop - INPUT_BOTTOM_RESERVED
  103. const minMessageArea = computeMinMessageAreaHeight(ctx.containerHeight)
  104. const containerAvailable = ctx.containerHeight - minMessageArea - ctx.fixedOverhead
  105. const effectiveMax = Math.min(
  106. Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(viewportAvailable)),
  107. Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerAvailable)),
  108. )
  109. return effectiveMax
  110. }
  111. export function getResizeBoundsForElement(rootEl: HTMLDivElement | null): ResizableInputBounds {
  112. if (!rootEl) {
  113. return { minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT, maxHeight: 200 }
  114. }
  115. const containerEl = findChatContainer(rootEl)
  116. const containerRect = containerEl?.getBoundingClientRect()
  117. const containerHeight = containerRect?.height ?? window.innerHeight
  118. const rootRect = getEffectiveRootRect(rootEl)
  119. const textareaEl = rootEl.querySelector("textarea")
  120. const measuredTextareaHeight = textareaEl?.getBoundingClientRect().height ?? 0
  121. const textareaHeight = measuredTextareaHeight > 0 ? measuredTextareaHeight : DEFAULT_RESIZABLE_INPUT_HEIGHT
  122. const chatInputOverhead = Math.max(0, rootRect.height - textareaHeight)
  123. const siblingOverhead = computeSiblingOverhead(rootEl, containerEl)
  124. const minMessageArea = computeMinMessageAreaHeight(containerHeight)
  125. const containerMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(containerHeight - minMessageArea - chatInputOverhead - siblingOverhead))
  126. const viewportMax = Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, Math.floor(window.innerHeight - rootRect.top - INPUT_BOTTOM_RESERVED))
  127. return {
  128. minHeight: DEFAULT_RESIZABLE_INPUT_HEIGHT,
  129. maxHeight: Math.min(containerMax, viewportMax),
  130. }
  131. }
  132. export function clampResizableInputHeight(
  133. nextHeight: number,
  134. bounds: ResizableInputBounds,
  135. ): number {
  136. const minHeight = Math.max(1, Math.floor(bounds.minHeight))
  137. const maxHeight = Math.max(minHeight, Math.floor(bounds.maxHeight))
  138. if (Number.isNaN(nextHeight)) return minHeight
  139. if (nextHeight === Number.POSITIVE_INFINITY) return maxHeight
  140. if (nextHeight === Number.NEGATIVE_INFINITY) return minHeight
  141. return Math.min(maxHeight, Math.max(minHeight, Math.round(nextHeight)))
  142. }
  143. export function isHeightAtMax(currentHeight: number, bounds: ResizableInputBounds): boolean {
  144. const maxHeight = Math.max(bounds.minHeight, Math.floor(bounds.maxHeight))
  145. return currentHeight >= maxHeight - 1
  146. }
  147. export function isHeightAtMin(currentHeight: number, bounds: ResizableInputBounds): boolean {
  148. return currentHeight <= bounds.minHeight + 1
  149. }