Explorar o código

fix(ui): 彻底修复AI大纲输入框底部溢出问题

- OutlineChatPanel 容器结构对齐 ChatPanel:根容器加 overflow-hidden,Header/Section/ChatInput 加 shrink-0,messages 加 min-h-0
- 新增 computeSiblingOverhead 函数计算 ChatInput 兄弟元素开销,纳入 fixedOverhead 防止 maxHeight 被高估
- 添加 ResizeObserver 监听容器变化,停靠模式切换/容器拖拽时自动 clamp textarea 高度
- 挂载时校验 localStorage 高度,超出当前容器 maxHeight 则自动缩小
Mochocyang hai 3 meses
pai
achega
999ed11cb4

+ 31 - 5
src/components/chat/chat-input-resize.ts

@@ -30,18 +30,19 @@ export function createResizeContext(rootEl: HTMLDivElement | null, currentInputH
   const textareaEl = rootEl.querySelector("textarea")
   const textareaRect = textareaEl?.getBoundingClientRect()
   const actualTextareaHeight = textareaRect?.height ?? currentInputHeight
-  const fixedOverhead = Math.max(0, rootRect.height - actualTextareaHeight)
+  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,
+    fixedOverhead: chatInputOverhead + siblingOverhead,
   }
 }
 
-function findChatContainer(root: HTMLDivElement): HTMLElement | null {
+export function findChatContainer(root: HTMLDivElement): HTMLElement | null {
   let current: HTMLElement | null = root.parentElement
   let fallback: HTMLElement | null = null
   for (let i = 0; i < 8 && current; i++) {
@@ -64,6 +65,30 @@ function findChatContainer(root: HTMLDivElement): HTMLElement | null {
   return fallback ?? 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
@@ -86,9 +111,10 @@ export function getResizeBoundsForElement(rootEl: HTMLDivElement | null): Resiza
   const rootRect = rootEl.getBoundingClientRect()
   const textareaEl = rootEl.querySelector("textarea")
   const textareaHeight = textareaEl?.getBoundingClientRect().height ?? DEFAULT_RESIZABLE_INPUT_HEIGHT
-  const fixedOverhead = Math.max(0, rootRect.height - textareaHeight)
+  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 - fixedOverhead))
+  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,

+ 67 - 30
src/components/chat/chat-input.tsx

@@ -11,6 +11,7 @@ import {
   resolveMaxHeightFromContext,
   isHeightAtMax,
   isHeightAtMin,
+  findChatContainer,
   type ResizeContext,
   type ResizableInputBounds,
 } from "./chat-input-resize"
@@ -91,43 +92,79 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder, leftContro
     userSetMinHeightRef.current = userSetMinHeight
   }, [userSetMinHeight])
 
-  useEffect(() => {
-    const handleResize = () => {
-      const userMin = userSetMinHeightRef.current
-      const ta = textareaRef.current
+  const handleContainerResize = useCallback(() => {
+    const userMin = userSetMinHeightRef.current
+    const ta = textareaRef.current
 
-      if (userMin != null) {
-        // 用户已手动设置高度:窗口 resize 时只做上下边界检查,保持高度尽量不变
-        const bounds = getResizeBoundsForElement(rootRef.current)
-        const current = inputHeightRef.current
-        const next = clampResizableInputHeight(current, bounds)
-        if (next !== current) {
-          if (ta) {
-            ta.style.height = `${next}px`
-          }
-          setInputHeight(next)
-          setUserSetMinHeight(next)
+    if (userMin != null) {
+      // 用户已手动设置高度:容器变化时只做上下边界检查,保持高度尽量不变
+      const bounds = getResizeBoundsForElement(rootRef.current)
+      const current = inputHeightRef.current
+      const next = clampResizableInputHeight(current, bounds)
+      if (next !== current) {
+        if (ta) {
+          ta.style.height = `${next}px`
         }
-        setIsAtLimitTop(isHeightAtMax(next, bounds))
-        setIsAtLimitBottom(isHeightAtMin(next, bounds))
-        return
+        setInputHeight(next)
+        setUserSetMinHeight(next)
+        // 同步到 localStorage,防止下次启动仍用超出边界的高度
+        saveInputHeight(next)
       }
+      setIsAtLimitTop(isHeightAtMax(next, bounds))
+      setIsAtLimitBottom(isHeightAtMin(next, bounds))
+      return
+    }
 
+    if (ta) {
+      const bounds = getResizeBoundsForElement(rootRef.current)
+      ta.style.height = "auto"
+      const contentHeight = ta.scrollHeight
+      const current = inputHeightRef.current
+      const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, contentHeight, current), bounds)
+      ta.style.height = `${next}px`
+      setInputHeight(next)
+    } else {
+      const bounds = getResizeBoundsForElement(rootRef.current)
+      setInputHeight((prev) => clampResizableInputHeight(prev, bounds))
+    }
+  }, [])
+
+  // 监听 window resize
+  useEffect(() => {
+    window.addEventListener("resize", handleContainerResize)
+    return () => window.removeEventListener("resize", handleContainerResize)
+  }, [handleContainerResize])
+
+  // 使用 ResizeObserver 监听容器大小变化(停靠模式切换、容器拖拽等)
+  useEffect(() => {
+    const rootEl = rootRef.current
+    if (!rootEl) return
+    const containerEl = findChatContainer(rootEl)
+    if (!containerEl || typeof ResizeObserver === "undefined") return
+
+    const observer = new ResizeObserver(() => {
+      handleContainerResize()
+    })
+    observer.observe(containerEl)
+    return () => observer.disconnect()
+  }, [handleContainerResize])
+
+  // 挂载时校验 localStorage 中保存的高度是否超出当前容器 maxHeight
+  // 防止在不同停靠模式(右侧大容器 vs 底部小容器)间切换时 textarea 高度溢出
+  useEffect(() => {
+    const saved = savedHeight.current
+    if (saved == null) return
+    const bounds = getResizeBoundsForElement(rootRef.current)
+    const clamped = clampResizableInputHeight(saved, bounds)
+    if (clamped !== saved) {
+      const ta = textareaRef.current
       if (ta) {
-        const bounds = getResizeBoundsForElement(rootRef.current)
-        ta.style.height = "auto"
-        const contentHeight = ta.scrollHeight
-        const current = inputHeightRef.current
-        const next = clampResizableInputHeight(Math.max(DEFAULT_RESIZABLE_INPUT_HEIGHT, contentHeight, current), bounds)
-        ta.style.height = `${next}px`
-        setInputHeight(next)
-      } else {
-        const bounds = getResizeBoundsForElement(rootRef.current)
-        setInputHeight((prev) => clampResizableInputHeight(prev, bounds))
+        ta.style.height = `${clamped}px`
       }
+      setInputHeight(clamped)
+      setUserSetMinHeight(clamped)
+      saveInputHeight(clamped)
     }
-    window.addEventListener("resize", handleResize)
-    return () => window.removeEventListener("resize", handleResize)
   }, [])
 
   const autoFitTextarea = useCallback(() => {

+ 2 - 2
src/components/sources/outline-chat-panel.tsx

@@ -556,7 +556,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   return (
     <div className="flex h-full flex-col overflow-hidden border-border bg-background">
       {/* Header with conversation tabs */}
-      <div className="flex items-center gap-1 border-b px-2 py-1.5 overflow-x-auto">
+      <div className="flex shrink-0 items-center gap-1 border-b px-2 py-1.5 overflow-x-auto">
         <button
           onClick={() => { createConversation() }}
           className="shrink-0 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
@@ -588,7 +588,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       </div>
 
       {/* Messages */}
-      <div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 space-y-3">
+      <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-3 py-2 space-y-3">
         {activeMessages.length === 0 && !isStreaming ? (
           <p className="text-center text-xs text-muted-foreground py-8">
             输入关于大纲的问题或指令,AI 会基于当前大纲和章节内容进行回答和创作。