Compare commits

...

1 commit

Author SHA1 Message Date
GuaGua
db618e3f05 fix(side-panel): open browser side panel from floating button 2026-04-03 01:19:36 -07:00
10 changed files with 168 additions and 129 deletions

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(side-panel): open the browser side panel from the floating button

View file

@ -0,0 +1,87 @@
import type { Browser } from "#imports"
import { describe, expect, it, vi } from "vitest"
import { openBrowserSidePanel } from "../side-panel"
function createSender(tab?: Partial<Browser.tabs.Tab>): Browser.runtime.MessageSender | undefined {
if (!tab) {
return undefined
}
return {
tab: tab as Browser.tabs.Tab,
}
}
describe("background side panel", () => {
it("opens the browser side panel for the sender tab when available", async () => {
const open = vi.fn().mockResolvedValue(undefined)
const opened = await openBrowserSidePanel(
{ sidePanel: { open } },
createSender({ id: 7, windowId: 3 }),
vi.fn(),
)
expect(opened).toBe(true)
expect(open).toHaveBeenCalledWith({ tabId: 7 })
})
it("falls back to the sender window when the tab id is unavailable", async () => {
const open = vi.fn().mockResolvedValue(undefined)
const opened = await openBrowserSidePanel(
{ sidePanel: { open } },
createSender({ windowId: 3 }),
vi.fn(),
)
expect(opened).toBe(true)
expect(open).toHaveBeenCalledWith({ windowId: 3 })
})
it("falls back to sidebarAction when the Chrome sidePanel API is unavailable", async () => {
const open = vi.fn().mockResolvedValue(undefined)
const opened = await openBrowserSidePanel(
{ sidebarAction: { open } },
createSender({ id: 7, windowId: 3 }),
vi.fn(),
)
expect(opened).toBe(true)
expect(open).toHaveBeenCalledOnce()
})
it("warns instead of throwing when sender tab context is unavailable", async () => {
const open = vi.fn().mockResolvedValue(undefined)
const warn = vi.fn()
const opened = await openBrowserSidePanel(
{ sidePanel: { open } },
undefined,
warn,
)
expect(opened).toBe(false)
expect(open).not.toHaveBeenCalled()
expect(warn).toHaveBeenCalledWith(
"[Background] Unable to open side panel without tab or window context",
{ sender: undefined },
)
})
it("warns instead of throwing when the side panel API rejects", async () => {
const error = new Error("gesture lost")
const open = vi.fn().mockRejectedValue(error)
const warn = vi.fn()
const opened = await openBrowserSidePanel(
{ sidePanel: { open } },
createSender({ id: 7, windowId: 3 }),
warn,
)
expect(opened).toBe(false)
expect(warn).toHaveBeenCalledWith("[Background] Failed to open side panel", error)
})
})

View file

@ -17,6 +17,7 @@ import { setupLLMGenerateTextMessageHandlers } from "./llm-generate-text"
import { initMockData } from "./mock-data"
import { newUserGuide } from "./new-user-guide"
import { proxyFetch } from "./proxy-fetch"
import { setupSidePanelMessageHandlers } from "./side-panel"
import { setUpSubtitlesTranslationQueue, setUpWebPageTranslationQueue } from "./translation-queues"
import { translationMessage } from "./translation-signal"
import { setupTTSPlaybackMessageHandlers } from "./tts-playback"
@ -54,6 +55,7 @@ export default defineBackground({
logger.info("openOptionsPage")
void browser.runtime.openOptionsPage()
})
setupSidePanelMessageHandlers()
onMessage("aiSegmentSubtitles", async (message) => {
try {

View file

@ -0,0 +1,60 @@
import type { Browser } from "#imports"
import { browser } from "#imports"
import { logger } from "@/utils/logger"
import { onMessage } from "@/utils/message"
interface SidePanelCompatBrowser {
sidePanel?: {
open: (options: Browser.sidePanel.OpenOptions) => Promise<void>
}
sidebarAction?: {
open: () => Promise<void>
}
}
export async function openBrowserSidePanel(
browserApi: SidePanelCompatBrowser,
sender?: Browser.runtime.MessageSender,
warn: (...args: unknown[]) => void = logger.warn,
): Promise<boolean> {
try {
if (browserApi.sidePanel?.open) {
const tabId = sender?.tab?.id
const windowId = sender?.tab?.windowId
if (typeof tabId === "number") {
await browserApi.sidePanel.open({ tabId })
return true
}
if (typeof windowId === "number") {
await browserApi.sidePanel.open({ windowId })
return true
}
warn("[Background] Unable to open side panel without tab or window context", { sender })
return false
}
if (browserApi.sidebarAction?.open) {
await browserApi.sidebarAction.open()
return true
}
}
catch (error) {
warn("[Background] Failed to open side panel", error)
return false
}
warn("[Background] Side panel API is not available in this browser")
return false
}
export function setupSidePanelMessageHandlers() {
onMessage("openSidePanel", async (message) => {
return await openBrowserSidePanel(
browser as unknown as SidePanelCompatBrowser,
message.sender,
)
})
}

View file

@ -1,12 +1,10 @@
import FrogToast from "@/components/frog-toast"
import FloatingButton from "./components/floating-button"
import SideContent from "./components/side-content"
export default function App() {
return (
<>
<FloatingButton />
<SideContent />
<FrogToast />
</>
)

View file

@ -3,8 +3,6 @@ import { createTranslationStateAtomForContentScript } from "@/utils/atoms/transl
export const store = createStore()
export const isSideOpenAtom = atom(false)
export const isDraggingButtonAtom = atom(false)
export const enablePageTranslationAtom = createTranslationStateAtomForContentScript(

View file

@ -16,7 +16,7 @@ import { APP_NAME } from "@/utils/constants/app"
import { sendMessage } from "@/utils/message"
import { cn } from "@/utils/styles/utils"
import { matchDomainPattern } from "@/utils/url"
import { enablePageTranslationAtom, isDraggingButtonAtom, isSideOpenAtom } from "../../atoms"
import { enablePageTranslationAtom, isDraggingButtonAtom } from "../../atoms"
import { shadowWrapper } from "../../index"
import HiddenButton from "./components/hidden-button"
import TranslateButton from "./translate-button"
@ -27,9 +27,7 @@ export default function FloatingButton() {
const [floatingButton, setFloatingButton] = useAtom(
configFieldsAtomMap.floatingButton,
)
const sideContent = useAtomValue(configFieldsAtomMap.sideContent)
const translationState = useAtomValue(enablePageTranslationAtom)
const [isSideOpen, setIsSideOpen] = useAtom(isSideOpenAtom)
const [isDraggingButton, setIsDraggingButton] = useAtom(isDraggingButtonAtom)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [dragPosition, setDragPosition] = useState<number | null>(null)
@ -114,7 +112,7 @@ export default function FloatingButton() {
})
}
else {
setIsSideOpen(o => !o)
void sendMessage("openSidePanel", undefined)
}
}
}
@ -123,7 +121,7 @@ export default function FloatingButton() {
document.addEventListener("mousemove", handleMouseMove)
}
const attachSideClassName = isDraggingButton || isSideOpen || isDropdownOpen ? "translate-x-0" : ""
const attachSideClassName = isDraggingButton || isDropdownOpen ? "translate-x-0" : ""
if (!floatingButton.enabled || floatingButton.disabledFloatingButtonPatterns.some(pattern => matchDomainPattern(window.location.href, pattern))) {
return null
@ -133,9 +131,7 @@ export default function FloatingButton() {
<div
className="group fixed z-2147483647 flex flex-col items-end gap-2 print:hidden"
style={{
right: isSideOpen
? `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`
: "var(--removed-body-scroll-bar-size, 0px)",
right: "var(--removed-body-scroll-bar-size, 0px)",
top: `${(dragPosition ?? floatingButton.position) * 100}vh`,
}}
>
@ -144,7 +140,7 @@ export default function FloatingButton() {
className={cn(
"border-border flex h-10 w-15 items-center rounded-l-full border border-r-0 bg-white opacity-60 shadow-lg group-hover:opacity-100 dark:bg-neutral-900",
"translate-x-5 transition-transform duration-300 group-hover:translate-x-0",
(isSideOpen || isDropdownOpen) && "opacity-100",
isDropdownOpen && "opacity-100",
isDraggingButton ? "cursor-move" : "cursor-pointer",
attachSideClassName,
)}

View file

@ -1,116 +0,0 @@
import { kebabCase } from "case-anything"
import { useAtom, useAtomValue } from "jotai"
import { useEffect, useState } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { APP_NAME } from "@/utils/constants/app"
import { MIN_SIDE_CONTENT_WIDTH } from "@/utils/constants/side"
import { cn } from "@/utils/styles/utils"
import { isSideOpenAtom } from "../../atoms"
export default function SideContent() {
const isSideOpen = useAtomValue(isSideOpenAtom)
const [sideContent, setSideContent] = useAtom(configFieldsAtomMap.sideContent)
const [isResizing, setIsResizing] = useState(false)
// const providersConfig = useAtomValue(configFieldsAtomMap.providersConfig)
// Setup resize handlers
useEffect(() => {
if (!isResizing)
return
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing)
return
const windowWidth = window.innerWidth
const newWidth = windowWidth - e.clientX
const clampedWidth = Math.max(MIN_SIDE_CONTENT_WIDTH, newWidth)
void setSideContent({ width: clampedWidth })
}
const handleMouseUp = () => {
setIsResizing(false)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = "none"
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = ""
}
}, [isResizing, setSideContent])
// HTML width adjustment
useEffect(() => {
const styleId = `shrink-origin-for-${kebabCase(APP_NAME)}-side-content`
let styleTag = document.getElementById(styleId)
if (isSideOpen) {
if (!styleTag) {
styleTag = document.createElement("style")
styleTag.id = styleId
document.head.appendChild(styleTag)
}
styleTag.textContent = `
html {
width: calc(100% - ${sideContent.width}px) !important;
position: relative !important;
min-height: 100vh !important;
}
`
}
else {
if (styleTag) {
document.head.removeChild(styleTag)
}
}
return () => {
if (styleTag && document.head.contains(styleTag)) {
document.head.removeChild(styleTag)
}
}
}, [isSideOpen, sideContent.width])
const handleResizeStart = (e: React.MouseEvent) => {
e.preventDefault()
setIsResizing(true)
}
return (
<>
<div
className={cn(
"bg-background fixed top-0 right-0 z-[2147483647] h-full pr-[var(--removed-body-scroll-bar-size,0px)]",
isSideOpen
? "border-border translate-x-0 border-l"
: "translate-x-full",
)}
style={{
width: `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`,
}}
>
{/* Resize handle */}
<div
className="absolute top-0 left-0 z-10 h-full w-2 cursor-ew-resize justify-center bg-transparent"
onMouseDown={handleResizeStart}
>
</div>
<div className="flex h-full flex-col gap-y-2 py-3 items-center justify-center">
The function is being upgraded
</div>
</div>
{/* Transparent overlay to prevent other events during resizing */}
{isResizing && (
<div className="fixed inset-0 z-[2147483647] cursor-ew-resize bg-transparent" />
)}
</>
)
}

View file

@ -0,0 +1,8 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Read Frog</title>
</head>
<body></body>
</html>

View file

@ -26,6 +26,7 @@ interface ProtocolMap {
// navigation
openPage: (data: { url: string, active?: boolean }) => void
openOptionsPage: () => void
openSidePanel: () => Promise<boolean>
// config
getInitialConfig: () => Config | null
// translation state