fix: scroll issue playground
This commit is contained in:
parent
15f29de34c
commit
f5f0157260
@ -65,3 +65,63 @@
|
||||
animation: gradient-border 3s infinite;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar by default */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show scrollbar on hover */
|
||||
.custom-scrollbar:hover {
|
||||
scrollbar-width: thin;
|
||||
-ms-overflow-style: auto;
|
||||
}
|
||||
|
||||
.custom-scrollbar:hover::-webkit-scrollbar {
|
||||
display: block;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles for light theme */
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-track {
|
||||
@apply bg-gray-50;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-400;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles for dark theme */
|
||||
.dark .custom-scrollbar:hover::-webkit-scrollbar-track {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: #404040;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar:hover::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #525252;
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
.custom-scrollbar {
|
||||
scrollbar-color: theme('colors.gray.300') theme('colors.gray.50');
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: #404040 #262626;
|
||||
}
|
||||
|
@ -78,6 +78,7 @@ export const Playground = () => {
|
||||
dropState === "dragging" ? "bg-gray-100 dark:bg-gray-800 z-10" : ""
|
||||
} bg-white dark:bg-[#171717]`}>
|
||||
<PlaygroundChat />
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex-grow">
|
||||
<div className="w-full flex justify-center">
|
||||
|
@ -3,6 +3,8 @@ import { useMessageOption } from "~/hooks/useMessageOption"
|
||||
import { PlaygroundEmpty } from "./PlaygroundEmpty"
|
||||
import { PlaygroundMessage } from "~/components/Common/Playground/Message"
|
||||
import { MessageSourcePopup } from "@/components/Common/Playground/MessageSourcePopup"
|
||||
import { useSmartScroll } from "~/hooks/useSmartScroll"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
export const PlaygroundChat = () => {
|
||||
const {
|
||||
@ -13,24 +15,24 @@ export const PlaygroundChat = () => {
|
||||
editMessage,
|
||||
ttsEnabled
|
||||
} = useMessageOption()
|
||||
const divRef = React.useRef<HTMLDivElement>(null)
|
||||
const [isSourceOpen, setIsSourceOpen] = React.useState(false)
|
||||
const [source, setSource] = React.useState<any>(null)
|
||||
React.useEffect(() => {
|
||||
if (divRef.current) {
|
||||
divRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
})
|
||||
|
||||
const { containerRef, isAtBottom, scrollToBottom } = useSmartScroll(
|
||||
messages,
|
||||
streaming
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{" "}
|
||||
<div className="grow flex flex-col md:translate-x-0 transition-transform duration-300 ease-in-out">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="custom-scrollbar grow flex flex-col md:translate-x-0 transition-transform duration-300 ease-in-out overflow-y-auto h-[calc(100vh-200px)]">
|
||||
{messages.length === 0 && (
|
||||
<div className="mt-32">
|
||||
<PlaygroundEmpty />
|
||||
</div>
|
||||
)}
|
||||
{/* {messages.length > 0 && <div className="w-full h-16 flex-shrink-0"></div>} */}
|
||||
{messages.map((message, index) => (
|
||||
<PlaygroundMessage
|
||||
key={index}
|
||||
@ -55,10 +57,18 @@ export const PlaygroundChat = () => {
|
||||
/>
|
||||
))}
|
||||
{messages.length > 0 && (
|
||||
<div className="w-full h-32 md:h-48 flex-shrink-0"></div>
|
||||
<div className="w-full h-16 flex-shrink-0"></div>
|
||||
)}
|
||||
<div ref={divRef} />
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
<div className="fixed md:bottom-44 bottom-36 z-[9999999] left-0 right-0 flex justify-center">
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="bg-gray-100 dark:bg-gray-800 p-1 rounded-full shadow-md hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200">
|
||||
<ChevronDown className="size-4 text-gray-600 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<MessageSourcePopup
|
||||
open={isSourceOpen}
|
||||
setOpen={setIsSourceOpen}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { getOllamaURL, isOllamaRunning } from "../services/ollama"
|
||||
import { browser } from "wxt/browser"
|
||||
import { setBadgeBackgroundColor, setBadgeText, setTitle } from "@/utils/action"
|
||||
|
||||
const progressHuman = (completed: number, total: number) => {
|
||||
return ((completed / total) * 100).toFixed(0) + "%"
|
||||
}
|
||||
@ -75,11 +76,12 @@ const streamDownload = async (url: string, model: string) => {
|
||||
clearBadge()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
export default defineBackground({
|
||||
main() {
|
||||
browser.runtime.onMessage.addListener(async (message) => {
|
||||
if (message.type === "sidepanel") {
|
||||
browser.sidebarAction.open()
|
||||
await browser.sidebarAction.open()
|
||||
} else if (message.type === "pull_model") {
|
||||
const ollamaURL = await getOllamaURL()
|
||||
|
||||
@ -100,7 +102,7 @@ export default defineBackground({
|
||||
|
||||
if (import.meta.env.BROWSER === "chrome") {
|
||||
chrome.action.onClicked.addListener((tab) => {
|
||||
browser.tabs.create({ url: browser.runtime.getURL("/options.html") })
|
||||
chrome.tabs.create({ url: chrome.runtime.getURL("/options.html") })
|
||||
})
|
||||
} else {
|
||||
browser.browserAction.onClicked.addListener((tab) => {
|
||||
@ -109,23 +111,31 @@ export default defineBackground({
|
||||
})
|
||||
}
|
||||
|
||||
const contextMenuTitle = {
|
||||
webUi: browser.i18n.getMessage("openOptionToChat"),
|
||||
sidePanel: browser.i18n.getMessage("openSidePanelToChat")
|
||||
}
|
||||
|
||||
const contextMenuId = {
|
||||
webUi: "open-web-ui-pa",
|
||||
sidePanel: "open-side-panel-pa"
|
||||
}
|
||||
|
||||
browser.contextMenus.create({
|
||||
id: "open-side-panel-pa",
|
||||
title: browser.i18n.getMessage("openSidePanelToChat"),
|
||||
id: contextMenuId["sidePanel"],
|
||||
title: contextMenuTitle["sidePanel"],
|
||||
contexts: ["all"]
|
||||
})
|
||||
if (import.meta.env.BROWSER === "chrome") {
|
||||
browser.contextMenus.onClicked.addListener((info, tab) => {
|
||||
if (info.menuItemId === "open-side-panel-pa") {
|
||||
chrome.tabs.query(
|
||||
{ active: true, currentWindow: true },
|
||||
async (tabs) => {
|
||||
const tab = tabs[0]
|
||||
chrome.sidePanel.open({
|
||||
tabId: tab.id!
|
||||
})
|
||||
}
|
||||
)
|
||||
chrome.sidePanel.open({
|
||||
tabId: tab.id!
|
||||
})
|
||||
} else if (info.menuItemId === "open-web-ui-pa") {
|
||||
browser.tabs.create({
|
||||
url: browser.runtime.getURL("/options.html")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@ -152,6 +162,10 @@ export default defineBackground({
|
||||
browser.contextMenus.onClicked.addListener((info, tab) => {
|
||||
if (info.menuItemId === "open-side-panel-pa") {
|
||||
browser.sidebarAction.toggle()
|
||||
} else if (info.menuItemId === "open-web-ui-pa") {
|
||||
browser.tabs.create({
|
||||
url: browser.runtime.getURL("/options.html")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -1,106 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useMessageOption } from "./useMessageOption"
|
||||
|
||||
export const useScrollAnchor = () => {
|
||||
const { isProcessing, messages } = useMessageOption()
|
||||
|
||||
const [isAtTop, setIsAtTop] = useState(false)
|
||||
const [isAtBottom, setIsAtBottom] = useState(true)
|
||||
const [userScrolled, setUserScrolled] = useState(false)
|
||||
const [isOverflowing, setIsOverflowing] = useState(false)
|
||||
|
||||
const messagesStartRef = useRef<HTMLDivElement>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isAutoScrolling = useRef(false)
|
||||
|
||||
console.log(`isAtTop: ${isAtTop}, isAtBottom: ${isAtBottom}, userScrolled: ${userScrolled}, isOverflowing: ${isOverflowing}`)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProcessing && userScrolled) {
|
||||
console.log("userScrolled")
|
||||
setUserScrolled(false)
|
||||
}
|
||||
}, [isProcessing])
|
||||
|
||||
useEffect(() => {
|
||||
if (isProcessing && !userScrolled) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const topObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsAtTop(entry.isIntersecting)
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
|
||||
const bottomObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsAtBottom(entry.isIntersecting)
|
||||
if (entry.isIntersecting) {
|
||||
setUserScrolled(false)
|
||||
} else if (!isAutoScrolling.current) {
|
||||
setUserScrolled(true)
|
||||
}
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
|
||||
if (messagesStartRef.current) {
|
||||
topObserver.observe(messagesStartRef.current)
|
||||
}
|
||||
|
||||
if (messagesEndRef.current) {
|
||||
bottomObserver.observe(messagesEndRef.current)
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
setIsOverflowing(container.scrollHeight > container.clientHeight)
|
||||
})
|
||||
|
||||
resizeObserver.observe(container)
|
||||
|
||||
return () => {
|
||||
topObserver.disconnect()
|
||||
bottomObserver.disconnect()
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
if (messagesStartRef.current) {
|
||||
messagesStartRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
isAutoScrolling.current = true
|
||||
|
||||
setTimeout(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
|
||||
isAutoScrolling.current = false
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
messagesStartRef,
|
||||
messagesEndRef,
|
||||
containerRef,
|
||||
isAtTop,
|
||||
isAtBottom,
|
||||
userScrolled,
|
||||
isOverflowing,
|
||||
scrollToTop,
|
||||
scrollToBottom,
|
||||
setIsAtBottom
|
||||
}
|
||||
}
|
35
src/hooks/useSmartScroll.tsx
Normal file
35
src/hooks/useSmartScroll.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
|
||||
export const useSmartScroll = (messages: any[], streaming: boolean) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
setIsAtBottom(scrollHeight - scrollTop - clientHeight < 50);
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAtBottom && containerRef.current) {
|
||||
const scrollOptions: ScrollIntoViewOptions = streaming
|
||||
? { behavior: 'smooth', block: 'end' }
|
||||
: { behavior: 'auto', block: 'end' };
|
||||
containerRef.current.lastElementChild?.scrollIntoView(scrollOptions);
|
||||
}
|
||||
}, [messages, streaming, isAtBottom]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
containerRef.current?.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
};
|
||||
|
||||
|
||||
return { containerRef, isAtBottom, scrollToBottom };
|
||||
};
|
@ -7,5 +7,8 @@
|
||||
},
|
||||
"openSidePanelToChat": {
|
||||
"message": "Open Copilot to Chat"
|
||||
},
|
||||
"openOptionToChat": {
|
||||
"message": "Open Web UI to Chat"
|
||||
}
|
||||
}
|
@ -80,3 +80,21 @@ export const getCustomOllamaHeaders = async (): Promise<
|
||||
|
||||
return headerMap
|
||||
}
|
||||
|
||||
export const getOpenOnIconClick = async (): Promise<string> => {
|
||||
const openOnIconClick = await storage.get<string>("openOnIconClick");
|
||||
return openOnIconClick || "webUI";
|
||||
};
|
||||
|
||||
export const setOpenOnIconClick = async (option: "webUI" | "sidePanel"): Promise<void> => {
|
||||
await storage.set("openOnIconClick", option);
|
||||
};
|
||||
|
||||
export const getOpenOnRightClick = async (): Promise<string> => {
|
||||
const openOnRightClick = await storage.get<string>("openOnRightClick");
|
||||
return openOnRightClick || "sidePanel";
|
||||
};
|
||||
|
||||
export const setOpenOnRightClick = async (option: "webUI" | "sidePanel"): Promise<void> => {
|
||||
await storage.set("openOnRightClick", option);
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user