|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; |
4 | 4 | import { |
5 | 5 | sendAgentMessage, |
6 | 6 | streamAgentRun, |
@@ -29,10 +29,64 @@ type ElementChatPopupProps = { |
29 | 29 |
|
30 | 30 | const POPUP_MIN_WIDTH = 360; |
31 | 31 | const POPUP_MAX_WIDTH = 480; |
32 | | -const POPUP_ESTIMATED_HEIGHT = 96; |
| 32 | +const POPUP_FALLBACK_HEIGHT = 132; |
33 | 33 | const VIEWPORT_PADDING = 12; |
34 | 34 | const ANCHOR_OVERLAP = 14; |
35 | 35 |
|
| 36 | +type PopupLayout = { left: number; top: number; width: number }; |
| 37 | + |
| 38 | +function computePopupWidth(anchorRect: ElementChatPopupProps["anchorRect"]): number { |
| 39 | + if (typeof window === "undefined") { |
| 40 | + return POPUP_MIN_WIDTH; |
| 41 | + } |
| 42 | + |
| 43 | + return Math.min( |
| 44 | + POPUP_MAX_WIDTH, |
| 45 | + Math.max(POPUP_MIN_WIDTH, anchorRect.width), |
| 46 | + window.innerWidth - VIEWPORT_PADDING * 2, |
| 47 | + ); |
| 48 | +} |
| 49 | + |
| 50 | +function computePopupLayout( |
| 51 | + anchorRect: ElementChatPopupProps["anchorRect"], |
| 52 | + popupHeight: number, |
| 53 | +): PopupLayout { |
| 54 | + if (typeof window === "undefined") { |
| 55 | + return { |
| 56 | + left: anchorRect.left, |
| 57 | + top: anchorRect.top + anchorRect.height - ANCHOR_OVERLAP, |
| 58 | + width: POPUP_MIN_WIDTH, |
| 59 | + }; |
| 60 | + } |
| 61 | + |
| 62 | + const width = computePopupWidth(anchorRect); |
| 63 | + const height = Math.min( |
| 64 | + popupHeight, |
| 65 | + window.innerHeight - VIEWPORT_PADDING * 2, |
| 66 | + ); |
| 67 | + |
| 68 | + let left = anchorRect.left; |
| 69 | + const maxLeft = window.innerWidth - width - VIEWPORT_PADDING; |
| 70 | + left = Math.max(VIEWPORT_PADDING, Math.min(left, maxLeft)); |
| 71 | + |
| 72 | + const belowTop = anchorRect.top + anchorRect.height - ANCHOR_OVERLAP; |
| 73 | + const aboveTop = anchorRect.top - height + ANCHOR_OVERLAP; |
| 74 | + const spaceBelow = window.innerHeight - VIEWPORT_PADDING - belowTop; |
| 75 | + const spaceAbove = anchorRect.top - VIEWPORT_PADDING; |
| 76 | + |
| 77 | + let top: number; |
| 78 | + if (height <= spaceBelow || spaceBelow >= spaceAbove) { |
| 79 | + top = belowTop; |
| 80 | + } else { |
| 81 | + top = aboveTop; |
| 82 | + } |
| 83 | + |
| 84 | + const maxTop = window.innerHeight - VIEWPORT_PADDING - height; |
| 85 | + top = Math.max(VIEWPORT_PADDING, Math.min(top, maxTop)); |
| 86 | + |
| 87 | + return { left, top, width }; |
| 88 | +} |
| 89 | + |
36 | 90 | type SpeechRecognitionResultList = { |
37 | 91 | length: number; |
38 | 92 | [index: number]: { |
@@ -74,26 +128,7 @@ function getSpeechRecognitionCtor(): (new () => SpeechRecognitionLike) | null { |
74 | 128 | } |
75 | 129 |
|
76 | 130 | function getPopupLayout(anchorRect: ElementChatPopupProps["anchorRect"]) { |
77 | | - if (typeof window === "undefined") { |
78 | | - return { left: anchorRect.left, top: anchorRect.top + anchorRect.height - ANCHOR_OVERLAP, width: POPUP_MIN_WIDTH }; |
79 | | - } |
80 | | - |
81 | | - const width = Math.min( |
82 | | - POPUP_MAX_WIDTH, |
83 | | - Math.max(POPUP_MIN_WIDTH, anchorRect.width), |
84 | | - window.innerWidth - VIEWPORT_PADDING * 2, |
85 | | - ); |
86 | | - |
87 | | - let left = anchorRect.left; |
88 | | - let top = anchorRect.top + anchorRect.height - ANCHOR_OVERLAP; |
89 | | - |
90 | | - const maxLeft = window.innerWidth - width - VIEWPORT_PADDING; |
91 | | - left = Math.max(VIEWPORT_PADDING, Math.min(left, maxLeft)); |
92 | | - |
93 | | - const maxTop = window.innerHeight - POPUP_ESTIMATED_HEIGHT - VIEWPORT_PADDING; |
94 | | - top = Math.max(VIEWPORT_PADDING, Math.min(top, maxTop)); |
95 | | - |
96 | | - return { left, top, width }; |
| 131 | + return computePopupLayout(anchorRect, POPUP_FALLBACK_HEIGHT); |
97 | 132 | } |
98 | 133 |
|
99 | 134 | function buildTranscript(event: SpeechRecognitionResultEvent) { |
@@ -141,9 +176,28 @@ export function ElementChatPopup({ |
141 | 176 | getSpeechRecognitionCtor() !== null && |
142 | 177 | typeof navigator.mediaDevices?.getUserMedia === "function"; |
143 | 178 | const [dictationError, setDictationError] = useState<string | null>(null); |
144 | | - const layout = getPopupLayout(anchorRect); |
| 179 | + const [layout, setLayout] = useState<PopupLayout>(() => getPopupLayout(anchorRect)); |
145 | 180 | const hasThread = messages.length > 0 || isThinking; |
146 | 181 |
|
| 182 | + const syncPopupLayout = useCallback(() => { |
| 183 | + const height = popupRef.current?.offsetHeight ?? POPUP_FALLBACK_HEIGHT; |
| 184 | + setLayout(computePopupLayout(anchorRect, height)); |
| 185 | + }, [anchorRect]); |
| 186 | + |
| 187 | + useLayoutEffect(() => { |
| 188 | + syncPopupLayout(); |
| 189 | + }, [syncPopupLayout, messages, isThinking, hasThread]); |
| 190 | + |
| 191 | + useEffect(() => { |
| 192 | + const schedule = () => syncPopupLayout(); |
| 193 | + window.addEventListener("resize", schedule); |
| 194 | + window.addEventListener("scroll", schedule, true); |
| 195 | + return () => { |
| 196 | + window.removeEventListener("resize", schedule); |
| 197 | + window.removeEventListener("scroll", schedule, true); |
| 198 | + }; |
| 199 | + }, [syncPopupLayout]); |
| 200 | + |
147 | 201 | const appendMessage = useCallback((role: Message["role"], content: string) => { |
148 | 202 | setMessages((prev) => [...prev, { id: crypto.randomUUID(), role, content }]); |
149 | 203 | }, []); |
|
0 commit comments