mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
Compare commits
1 commit
main
...
feat/side-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db618e3f05 |
10 changed files with 168 additions and 129 deletions
5
.changeset/wise-apricots-tickle.md
Normal file
5
.changeset/wise-apricots-tickle.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(side-panel): open the browser side panel from the floating button
|
||||
87
src/entrypoints/background/__tests__/side-panel.test.ts
Normal file
87
src/entrypoints/background/__tests__/side-panel.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
60
src/entrypoints/background/side-panel.ts
Normal file
60
src/entrypoints/background/side-panel.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
8
src/entrypoints/sidepanel/index.html
Normal file
8
src/entrypoints/sidepanel/index.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Read Frog</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue