Compare commits

..

3 commits

Author SHA1 Message Date
MengXi
120dc01f4f refactor: simplify floating button controls 2026-04-25 21:34:10 -07:00
Yiou Li
ed5319949f chore: add changeset 2026-04-25 21:00:08 -07:00
Yiou Li
7778259f89 feat: add floating button controls 2026-04-25 18:30:55 -07:00
69 changed files with 582 additions and 2565 deletions

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(extension): ensure Defuddle webpage context returns Markdown

View file

@ -1,5 +0,0 @@
---
"@read-frog/extension": patch
---
fix(options): preserve focused provider options drafts

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": minor
---
feat(subtitles): add subtitle style settings panel with Trancy-inspired UI

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
feat: add floating button controls

View file

@ -1,5 +0,0 @@
---
"@read-frog/extension": patch
---
fix(page-translation): re-walk revealed accordion content

View file

@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
style(extension): align primary theme tokens and translation brand colors

View file

@ -27,22 +27,5 @@ copy_env() {
fi
}
copy_source_file() {
rel="$1"
src="$CODEX_SOURCE_TREE_PATH/$rel"
dst="$CODEX_WORKTREE_PATH/$rel"
mkdir -p "$(dirname "$dst")"
if [ -f "$src" ]; then
cp "$src" "$dst"
echo "copied $rel from source tree"
else
echo "skipped $rel (no source found)"
fi
}
copy_env ".env.development" ".env.example"
copy_source_file "web-ext.config.ts"
'''

View file

@ -1,33 +1,5 @@
# @read-frog/extension
## 1.33.1
### Patch Changes
- [#1394](https://github.com/mengxi-ream/read-frog/pull/1394) [`619c83d`](https://github.com/mengxi-ream/read-frog/commit/619c83defd417ad2c68c8e0c6258afe5e5d79b04) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add embed translate button and settings panel injection
- [#1402](https://github.com/mengxi-ream/read-frog/pull/1402) [`0bd869f`](https://github.com/mengxi-ream/read-frog/commit/0bd869fd935738adcddae76f84c1232313168099) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(extension): guard popup account avatar session state
- [#1397](https://github.com/mengxi-ream/read-frog/pull/1397) [`466c1ce`](https://github.com/mengxi-ream/read-frog/commit/466c1cefdb78726fd870d979ec90c41beafbaa38) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - feat(extension): open native side panel from floating button
- [#1400](https://github.com/mengxi-ream/read-frog/pull/1400) [`c3debfb`](https://github.com/mengxi-ream/read-frog/commit/c3debfbc0c2fe3ebf6c53937c63ca3d745ee4c0e) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - style(options): widen Google Drive sync conflict dialog
## 1.33.0
### Minor Changes
- [#1388](https://github.com/mengxi-ream/read-frog/pull/1388) [`6922155`](https://github.com/mengxi-ream/read-frog/commit/69221554ff0a4db662ce9dff3304ea8923f46c8e) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(subtitles): add subtitle style settings panel with Trancy-inspired UI
- [#1392](https://github.com/mengxi-ream/read-frog/pull/1392) [`596bcf7`](https://github.com/mengxi-ream/read-frog/commit/596bcf7248ddeea7bea843143bcdab52b41a5048) Thanks [@taiiiyang](https://github.com/taiiiyang)! - feat(extension): support YouTube embed subtitles on third-party sites
### Patch Changes
- [#1385](https://github.com/mengxi-ream/read-frog/pull/1385) [`746a3c5`](https://github.com/mengxi-ream/read-frog/commit/746a3c5c3b71d83a4404db7c26a37c44acc031ae) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - fix(extension): ensure Defuddle webpage context returns Markdown
- [#1391](https://github.com/mengxi-ream/read-frog/pull/1391) [`afa7dee`](https://github.com/mengxi-ream/read-frog/commit/afa7dee1b0b8fcd26559d8a8590e51649166c3a9) Thanks [@li-yiou](https://github.com/li-yiou)! - feat: add floating button controls
- [#1389](https://github.com/mengxi-ream/read-frog/pull/1389) [`c25b299`](https://github.com/mengxi-ream/read-frog/commit/c25b299ca474f3c2baccf9e4a629d6e042dcfbcc) Thanks [@mengxi-ream](https://github.com/mengxi-ream)! - style(extension): align primary theme tokens and translation brand colors
## 1.32.4
### Patch Changes

View file

@ -1,7 +1,7 @@
{
"name": "@read-frog/extension",
"type": "module",
"version": "1.33.1",
"version": "1.32.4",
"private": true,
"packageManager": "pnpm@10.32.1",
"description": "Read Frog browser extension for language learning",

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(8 8) scale(0.75) translate(-8 -8)" d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
</svg>
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="m 8 1 c -1.65625 0 -3 1.34375 -3 3 s 1.34375 3 3 3 s 3 -1.34375 3 -3 s -1.34375 -3 -3 -3 z m -1.5 7 c -2.492188 0 -4.5 2.007812 -4.5 4.5 v 0.5 c 0 1.109375 0.890625 2 2 2 h 8 c 1.109375 0 2 -0.890625 2 -2 v -0.5 c 0 -2.492188 -2.007812 -4.5 -4.5 -4.5 z m 0 0" fill="#6a7282"/>
</svg>

Before

Width:  |  Height:  |  Size: 565 B

After

Width:  |  Height:  |  Size: 510 B

Before After
Before After

View file

@ -1,115 +0,0 @@
// @vitest-environment jsdom
import type { ComponentProps } from "react"
import { render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import guest from "@/assets/icons/avatars/guest.svg"
const { sessionState, useSessionMock } = vi.hoisted(() => ({
sessionState: {
data: null as unknown,
isPending: false,
},
useSessionMock: vi.fn(() => sessionState),
}))
vi.mock("@/env", () => ({
env: {
WXT_WEBSITE_URL: "https://readfrog.app",
},
}))
vi.mock("@/utils/auth/auth-client", () => ({
authClient: {
useSession: useSessionMock,
},
}))
vi.mock("@/components/ui/base-ui/avatar", () => ({
Avatar: ({
children,
className,
size = "default",
}: ComponentProps<"span"> & { size?: "default" | "sm" | "lg" }) => (
<span data-slot="avatar" data-size={size} className={className}>
{children}
</span>
),
AvatarImage: (props: ComponentProps<"img">) => props.src ? <img data-slot="avatar-image" {...props} /> : null,
AvatarFallback: ({ children }: ComponentProps<"span">) => (
<span data-slot="avatar-fallback">{children}</span>
),
}))
describe("user account", () => {
beforeEach(() => {
vi.clearAllMocks()
sessionState.data = null
sessionState.isPending = false
})
it("shows the guest image and login action when signed out", async () => {
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
const image = screen.getByRole("img", { name: "Guest" })
expect(image).toHaveAttribute("src", guest)
expect(screen.getByText("Guest")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
})
it("treats a session payload without user as signed out", async () => {
sessionState.data = { session: { id: "session-1" } }
const { UserAccount } = await import("../user-account")
expect(() => render(<UserAccount />)).not.toThrow()
const image = screen.getByRole("img", { name: "Guest" })
expect(image).toHaveAttribute("src", guest)
expect(screen.getByText("Guest")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Log in" })).toBeInTheDocument()
})
it("shows the user's avatar and name when signed in with an image", async () => {
sessionState.data = {
user: {
name: "John Doe",
image: "https://cdn.example.com/john.png",
},
}
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
expect(screen.getByRole("img", { name: "John Doe" })).toHaveAttribute("src", "https://cdn.example.com/john.png")
expect(screen.getByText("John Doe")).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
})
it("uses initials fallback for signed-in users without an image", async () => {
sessionState.data = {
user: {
name: "John Doe",
image: null,
},
}
const { UserAccount } = await import("../user-account")
render(<UserAccount />)
expect(screen.queryByRole("img", { name: "John Doe" })).not.toBeInTheDocument()
expect(screen.getByText("JD")).toBeInTheDocument()
expect(screen.getByText("John Doe")).toBeInTheDocument()
})
it("keeps the loading state without showing login", async () => {
sessionState.isPending = true
const { UserAccount } = await import("../user-account")
const view = render(<UserAccount />)
expect(screen.getByText("Loading...")).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Log in" })).not.toBeInTheDocument()
expect(view.container.querySelector("[data-slot='avatar']")).toHaveClass("animate-pulse")
})
})

View file

@ -15,11 +15,9 @@ export const ThemeContext = createContext<ThemeContextI | undefined>(undefined)
export function ThemeProvider({
children,
container,
forcedTheme,
}: {
children: React.ReactNode
container?: HTMLElement
forcedTheme?: Theme
}) {
const [themeMode, setThemeMode] = useAtom(themeModeAtom)
@ -36,12 +34,10 @@ export function ThemeProvider({
() => !!window?.matchMedia?.("(prefers-color-scheme: dark)")?.matches,
)
const resolvedTheme: Theme = themeMode === "system"
const theme: Theme = themeMode === "system"
? (prefersDark ? "dark" : "light")
: themeMode
const theme: Theme = forcedTheme ?? resolvedTheme
// Apply theme to document or shadow root container
useLayoutEffect(() => {
const target = container ?? document.documentElement

View file

@ -1,107 +0,0 @@
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import * as React from "react"
import { cn } from "@/utils/styles/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className,
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
)
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
}

View file

@ -1,38 +1,20 @@
import guest from "@/assets/icons/avatars/guest.svg"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/base-ui/avatar"
import { Button } from "@/components/ui/base-ui/button"
import { env } from "@/env"
import { authClient } from "@/utils/auth/auth-client"
import { cn } from "@/utils/styles/utils"
function getUserInitials(name: string | null | undefined) {
const normalizedName = name?.trim()
if (!normalizedName)
return "U"
const parts = normalizedName.split(/\s+/)
const initials = parts.length > 1
? `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`
: Array.from(normalizedName).slice(0, 2).join("")
return initials.toUpperCase()
}
export function UserAccount() {
const { data, isPending } = authClient.useSession()
const user = data?.user
const displayName = user?.name?.trim() || "Guest"
const avatarSrc = user ? user.image : guest
const fallbackText = user ? getUserInitials(user.name) : "G"
return (
<div className="flex items-center gap-2">
<Avatar size="sm" className={cn(isPending && "animate-pulse")}>
<AvatarImage src={avatarSrc || ""} alt={displayName} />
<AvatarFallback>{fallbackText}</AvatarFallback>
</Avatar>
{isPending ? "Loading..." : displayName}
{!isPending && !user && (
<img
src={data?.user.image ?? guest}
alt="User"
className={cn("rounded-full border size-6", !data?.user.image && "p-1", isPending && "animate-pulse")}
/>
{isPending ? "Loading..." : data?.user.name ?? "Guest"}
{!isPending && !data && (
<Button
size="xs"
variant="outline"

View file

@ -1,279 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { createSidePanelWindowState, createToggleSidePanelHandler, getSidePanelApi, setupSidePanelMessageHandler } from "../side-panel"
function createLogger() {
return {
error: vi.fn(),
warn: vi.fn(),
}
}
const senderWindowMessage = {
sender: {
tab: {
id: 123,
windowId: 456,
},
},
}
function chromiumSidePanel<TApi>(api: TApi) {
return {
kind: "chromium-side-panel" as const,
api,
}
}
function firefoxSidebarAction<TApi>(api: TApi) {
return {
kind: "firefox-sidebar-action" as const,
api,
}
}
describe("background side panel", () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it("opens the global side panel synchronously so Chrome keeps the user gesture", async () => {
const logger = createLogger()
const calls: string[] = []
const sidePanel = {
setOptions: vi.fn(() => {
calls.push("setOptions")
}),
open: vi.fn((_options: { windowId: number }) => {
calls.push("open")
return Promise.resolve()
}),
}
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
})
const result = handler(senderWindowMessage)
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
expect(sidePanel.open.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
expect(sidePanel.setOptions).not.toHaveBeenCalled()
expect(calls).toEqual(["open"])
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
})
it("closes the global side panel when the sender window is already open", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const calls: string[] = []
const sidePanel = {
close: vi.fn((_options: { windowId: number }) => {
calls.push("close")
return Promise.resolve()
}),
open: vi.fn((_options: { windowId: number }) => {
calls.push("open")
return Promise.resolve()
}),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
expect(sidePanel.close.mock.calls[0]?.[0]).not.toHaveProperty("tabId")
expect(sidePanel.open).not.toHaveBeenCalled()
expect(calls).toEqual(["close"])
expect(windowState.isOpen(456)).toBe(false)
})
it("tracks browser side panel open and close events for toggle state", async () => {
const logger = createLogger()
const onOpenedListeners: Array<(info: { windowId?: number }) => void> = []
const onClosedListeners: Array<(info: { windowId?: number }) => void> = []
const registeredMessageHandlers = new Map<string, (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>>()
const sidePanel = {
close: vi.fn().mockResolvedValue(undefined),
open: vi.fn().mockResolvedValue(undefined),
onClosed: {
addListener: vi.fn((listener) => {
onClosedListeners.push(listener)
}),
},
onOpened: {
addListener: vi.fn((listener) => {
onOpenedListeners.push(listener)
}),
},
}
setupSidePanelMessageHandler({
extensionBrowser: { sidePanel } as any,
logger,
registerMessageHandler: ((type: string, handler: (message: typeof senderWindowMessage) => Promise<{ ok: true } | { ok: false, reason: string }>) => {
registeredMessageHandlers.set(type, handler)
}) as any,
})
onOpenedListeners[0]?.({ windowId: 456 })
const handler = registeredMessageHandlers.get("toggleSidePanel")
if (!handler) {
throw new Error("toggleSidePanel handler was not registered")
}
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "closed" })
expect(sidePanel.close).toHaveBeenCalledWith({ windowId: 456 })
onClosedListeners[0]?.({ windowId: 456 })
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
})
it("returns an unsupported result when closing is unavailable", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const sidePanel = {
open: vi.fn(),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
expect(logger.warn).toHaveBeenCalledWith("Side panel close API is unavailable in this browser")
expect(sidePanel.open).not.toHaveBeenCalled()
})
it("clears stale open state when Chrome rejects close", async () => {
const logger = createLogger()
const windowState = createSidePanelWindowState()
const error = new Error("No active global side panel")
const sidePanel = {
close: vi.fn().mockRejectedValue(error),
open: vi.fn().mockResolvedValue(undefined),
}
windowState.markOpened({ windowId: 456 })
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel(sidePanel),
logger,
windowState,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
expect(logger.error).toHaveBeenCalledWith("Failed to close side panel", error)
expect(windowState.isOpen(456)).toBe(false)
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: true, action: "opened" })
expect(sidePanel.open).toHaveBeenCalledWith({ windowId: 456 })
})
it("returns an unsupported result when the side panel API is unavailable", async () => {
const logger = createLogger()
const handler = createToggleSidePanelHandler({
getApi: () => null,
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "unsupported" })
expect(logger.warn).toHaveBeenCalledWith("Side panel API is unavailable in this browser")
})
it("does not open the Firefox sidebar from a content-script message", async () => {
const logger = createLogger()
const sidebarAction = {
open: vi.fn(),
}
const handler = createToggleSidePanelHandler({
getApi: () => firefoxSidebarAction(sidebarAction),
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({
ok: false,
reason: "requires-extension-user-action",
})
expect(sidebarAction.open).not.toHaveBeenCalled()
expect(logger.warn).toHaveBeenCalledWith("Firefox sidebar requires an extension user action")
})
it("opens the Firefox sidebar when called from an extension user action", async () => {
const logger = createLogger()
const sidebarAction = {
open: vi.fn().mockResolvedValue(undefined),
}
const handler = createToggleSidePanelHandler({
getApi: () => firefoxSidebarAction(sidebarAction),
logger,
})
const result = handler({ data: { source: "extension-user-action" } })
expect(sidebarAction.open).toHaveBeenCalled()
await expect(result).resolves.toEqual({ ok: true, action: "opened" })
})
it("returns a missing-window result when the sender window id is unavailable", async () => {
const logger = createLogger()
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel({ open: vi.fn() }),
logger,
})
await expect(handler({ sender: { tab: { id: 123 } } })).resolves.toEqual({ ok: false, reason: "missing-window" })
expect(logger.warn).toHaveBeenCalledWith(
"Cannot toggle side panel without a sender window",
{ sender: { tab: { id: 123 } } },
)
})
it("returns a toggle-failed result when Chrome rejects the open request", async () => {
const logger = createLogger()
const error = new Error("sidePanel.open() may only be called in response to a user gesture")
const handler = createToggleSidePanelHandler({
getApi: () => chromiumSidePanel({
open: vi.fn().mockRejectedValue(error),
}),
logger,
})
await expect(handler(senderWindowMessage)).resolves.toEqual({ ok: false, reason: "toggle-failed" })
expect(logger.error).toHaveBeenCalledWith("Failed to open side panel", error)
})
it("finds the Chrome sidePanel API when the WXT browser wrapper does not expose it", () => {
const sidePanel = {
open: vi.fn(),
}
vi.stubGlobal("chrome", {
sidePanel,
})
expect(getSidePanelApi({} as any)).toEqual({
kind: "chromium-side-panel",
api: sidePanel,
})
})
it("finds the Firefox sidebarAction API from the WXT browser wrapper", () => {
const sidebarAction = {
open: vi.fn(),
}
expect(getSidePanelApi({ sidebarAction } as any)).toEqual({
kind: "firefox-sidebar-action",
api: sidebarAction,
})
})
})

View file

@ -18,7 +18,6 @@ import { setupLLMGenerateTextMessageHandlers } from "./llm-generate-text"
import { initMockData } from "./mock-data"
import { newUserGuide } from "./new-user-guide"
import { proxyFetch } from "./proxy-fetch"
import { setupSidePanelMessageHandler } from "./side-panel"
import { setUpSubtitlesTranslationQueue, setUpWebPageTranslationQueue } from "./translation-queues"
import { translationMessage } from "./translation-signal"
import { setupTTSPlaybackMessageHandlers } from "./tts-playback"
@ -57,12 +56,6 @@ export default defineBackground({
await openOptionsPage()
})
setupSidePanelMessageHandler({
extensionBrowser: browser,
logger,
registerMessageHandler: onMessage,
})
onMessage("aiSegmentSubtitles", async (message) => {
try {
return await runAiSegmentSubtitles(message.data)

View file

@ -1,276 +0,0 @@
import type { browser } from "#imports"
import type { onMessage } from "@/utils/message"
interface ChromiumSidePanelApi {
close?: (options: { windowId: number }) => Promise<void> | void
open: (options: { windowId: number }) => Promise<void> | void
onClosed?: SidePanelEvent<SidePanelStateInfo>
onOpened?: SidePanelEvent<SidePanelStateInfo>
}
interface FirefoxSidebarActionApi {
close?: () => Promise<void> | void
open?: () => Promise<void> | void
toggle?: () => Promise<void> | void
}
type BrowserSidePanelApi
= | { kind: "chromium-side-panel", api: ChromiumSidePanelApi }
| { kind: "firefox-sidebar-action", api: FirefoxSidebarActionApi }
interface SidePanelEvent<TInfo> {
addListener: (callback: (info: TInfo) => void) => void
}
interface SidePanelStateInfo {
windowId?: number
}
interface ToggleSidePanelMessage {
data?: {
source?: "content-script" | "extension-user-action"
}
sender?: {
tab?: {
id?: number
windowId?: number
}
}
}
type ToggleSidePanelResult
= | { ok: true, action: "opened" | "closed" }
| { ok: false, reason: "missing-window" | "unsupported" | "toggle-failed" | "requires-extension-user-action" }
interface SidePanelLogger {
error: (...args: any[]) => void
warn: (...args: any[]) => void
}
export function createSidePanelWindowState() {
const activeWindowIds = new Set<number>()
return {
isOpen(windowId: number) {
return activeWindowIds.has(windowId)
},
markClosed(info: SidePanelStateInfo) {
if (typeof info.windowId === "number") {
activeWindowIds.delete(info.windowId)
}
},
markOpened(info: SidePanelStateInfo) {
if (typeof info.windowId === "number") {
activeWindowIds.add(info.windowId)
}
},
}
}
function getToggleSource(message: ToggleSidePanelMessage) {
return message.data?.source ?? "content-script"
}
function toChromiumSidePanelApi(api: Partial<ChromiumSidePanelApi> | undefined): BrowserSidePanelApi | null {
if (typeof api?.open !== "function") {
return null
}
return {
kind: "chromium-side-panel",
api: api as ChromiumSidePanelApi,
}
}
function toFirefoxSidebarActionApi(api: Partial<FirefoxSidebarActionApi> | undefined): BrowserSidePanelApi | null {
if (typeof api?.open !== "function" && typeof api?.toggle !== "function") {
return null
}
return {
kind: "firefox-sidebar-action",
api: api as FirefoxSidebarActionApi,
}
}
export function getSidePanelApi(extensionBrowser: typeof browser): BrowserSidePanelApi | null {
const browserWithSidePanel = extensionBrowser as typeof extensionBrowser & { sidePanel?: Partial<ChromiumSidePanelApi> }
if (typeof browserWithSidePanel.sidePanel?.open === "function") {
return toChromiumSidePanelApi(browserWithSidePanel.sidePanel)
}
const globalWithChrome = globalThis as typeof globalThis & {
chrome?: { sidePanel?: Partial<ChromiumSidePanelApi> }
}
if (typeof globalWithChrome.chrome?.sidePanel?.open === "function") {
return toChromiumSidePanelApi(globalWithChrome.chrome.sidePanel)
}
const browserWithSidebarAction = extensionBrowser as typeof extensionBrowser & { sidebarAction?: Partial<FirefoxSidebarActionApi> }
const sidebarAction = toFirefoxSidebarActionApi(browserWithSidebarAction.sidebarAction)
if (sidebarAction) {
return sidebarAction
}
const globalWithBrowser = globalThis as typeof globalThis & {
browser?: { sidebarAction?: Partial<FirefoxSidebarActionApi> }
}
const globalSidebarAction = toFirefoxSidebarActionApi(globalWithBrowser.browser?.sidebarAction)
if (globalSidebarAction) {
return globalSidebarAction
}
return null
}
function toggleFirefoxSidebarAction({
api,
logger,
source,
}: {
api: FirefoxSidebarActionApi
logger: SidePanelLogger
source: ReturnType<typeof getToggleSource>
}): Promise<ToggleSidePanelResult> {
if (source !== "extension-user-action") {
logger.warn("Firefox sidebar requires an extension user action")
return Promise.resolve({ ok: false, reason: "requires-extension-user-action" } as const)
}
const openSidebar = typeof api.open === "function"
? () => api.open?.()
: typeof api.toggle === "function"
? () => api.toggle?.()
: null
if (!openSidebar) {
logger.warn("Firefox sidebar open API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
try {
const openResult = openSidebar()
return Promise.resolve(openResult)
.then(() => ({ ok: true, action: "opened" } as const))
.catch((error) => {
logger.error("Failed to open Firefox sidebar", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
logger.error("Failed to open Firefox sidebar", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
export function createToggleSidePanelHandler({
getApi,
logger,
windowState = createSidePanelWindowState(),
}: {
getApi: () => BrowserSidePanelApi | null
logger: SidePanelLogger
windowState?: ReturnType<typeof createSidePanelWindowState>
}) {
return (message: ToggleSidePanelMessage): Promise<ToggleSidePanelResult> => {
const browserSidePanel = getApi()
if (!browserSidePanel) {
logger.warn("Side panel API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
if (browserSidePanel.kind === "firefox-sidebar-action") {
return toggleFirefoxSidebarAction({
api: browserSidePanel.api,
logger,
source: getToggleSource(message),
})
}
const windowId = message.sender?.tab?.windowId
if (typeof windowId !== "number") {
logger.warn("Cannot toggle side panel without a sender window", message)
return Promise.resolve({ ok: false, reason: "missing-window" } as const)
}
const sidePanel = browserSidePanel.api
if (windowState.isOpen(windowId)) {
if (typeof sidePanel.close !== "function") {
logger.warn("Side panel close API is unavailable in this browser")
return Promise.resolve({ ok: false, reason: "unsupported" } as const)
}
try {
const closeResult = sidePanel.close({ windowId })
return Promise.resolve(closeResult)
.then(() => {
windowState.markClosed({ windowId })
return { ok: true, action: "closed" } as const
})
.catch((error) => {
windowState.markClosed({ windowId })
logger.error("Failed to close side panel", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
windowState.markClosed({ windowId })
logger.error("Failed to close side panel", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
try {
// Chrome requires sidePanel.open() to run directly in the user-gesture
// task. Do not await other async APIs before this call.
const openResult = sidePanel.open({ windowId })
return Promise.resolve(openResult)
.then(() => {
windowState.markOpened({ windowId })
return { ok: true, action: "opened" } as const
})
.catch((error) => {
logger.error("Failed to open side panel", error)
return { ok: false, reason: "toggle-failed" } as const
})
}
catch (error) {
logger.error("Failed to open side panel", error)
return Promise.resolve({ ok: false, reason: "toggle-failed" } as const)
}
}
}
export function setupSidePanelMessageHandler({
extensionBrowser,
logger,
registerMessageHandler,
}: {
extensionBrowser: typeof browser
logger: SidePanelLogger
registerMessageHandler: typeof onMessage
}) {
const windowState = createSidePanelWindowState()
const sidePanel = getSidePanelApi(extensionBrowser)
if (sidePanel?.kind !== "chromium-side-panel") {
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
getApi: () => getSidePanelApi(extensionBrowser),
logger,
windowState,
}))
return
}
sidePanel.api.onOpened?.addListener((info) => {
windowState.markOpened(info)
})
sidePanel.api.onClosed?.addListener((info) => {
windowState.markClosed(info)
})
registerMessageHandler("toggleSidePanel", createToggleSidePanelHandler({
getApi: () => getSidePanelApi(extensionBrowser),
logger,
windowState,
}))
}

View file

@ -1,307 +0,0 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest"
import { DEFAULT_CONFIG } from "@/utils/constants/config"
import { PageTranslationManager } from "../page-translation"
const {
mockDeepQueryTopLevelSelector,
mockGetDetectedCodeFromStorage,
mockGetLocalConfig,
mockGetOrCreateWebPageContext,
mockHasNoWalkAncestor,
mockIsDontWalkIntoAndDontTranslateAsChildElement,
mockIsDontWalkIntoButTranslateAsChildElement,
mockRemoveAllTranslatedWrapperNodes,
mockSendMessage,
mockTranslateTextForPageTitle,
mockTranslateWalkedElement,
mockValidateTranslationConfigAndToast,
mockWalkAndLabelElement,
} = vi.hoisted(() => ({
mockGetDetectedCodeFromStorage: vi.fn(),
mockGetLocalConfig: vi.fn(),
mockGetOrCreateWebPageContext: vi.fn(),
mockDeepQueryTopLevelSelector: vi.fn(),
mockHasNoWalkAncestor: vi.fn(),
mockIsDontWalkIntoAndDontTranslateAsChildElement: vi.fn(),
mockIsDontWalkIntoButTranslateAsChildElement: vi.fn(),
mockWalkAndLabelElement: vi.fn(),
mockRemoveAllTranslatedWrapperNodes: vi.fn(),
mockTranslateWalkedElement: vi.fn(),
mockTranslateTextForPageTitle: vi.fn(),
mockValidateTranslationConfigAndToast: vi.fn(),
mockSendMessage: vi.fn(),
}))
vi.mock("@/utils/config/languages", () => ({
getDetectedCodeFromStorage: mockGetDetectedCodeFromStorage,
}))
vi.mock("@/utils/config/storage", () => ({
getLocalConfig: mockGetLocalConfig,
}))
vi.mock("@/utils/crypto-polyfill", () => ({
getRandomUUID: () => "walk-id",
}))
vi.mock("@/utils/host/dom/filter", () => ({
hasNoWalkAncestor: mockHasNoWalkAncestor,
isDontWalkIntoAndDontTranslateAsChildElement: mockIsDontWalkIntoAndDontTranslateAsChildElement,
isDontWalkIntoButTranslateAsChildElement: mockIsDontWalkIntoButTranslateAsChildElement,
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
}))
vi.mock("@/utils/host/dom/find", () => ({
deepQueryTopLevelSelector: mockDeepQueryTopLevelSelector,
}))
vi.mock("@/utils/host/dom/traversal", () => ({
walkAndLabelElement: mockWalkAndLabelElement,
}))
vi.mock("@/utils/host/translate/node-manipulation", () => ({
removeAllTranslatedWrapperNodes: mockRemoveAllTranslatedWrapperNodes,
translateWalkedElement: mockTranslateWalkedElement,
}))
vi.mock("@/utils/host/translate/translate-text", () => ({
validateTranslationConfigAndToast: mockValidateTranslationConfigAndToast,
}))
vi.mock("@/utils/host/translate/translate-variants", () => ({
translateTextForPageTitle: mockTranslateTextForPageTitle,
}))
vi.mock("@/utils/host/translate/webpage-context", () => ({
getOrCreateWebPageContext: mockGetOrCreateWebPageContext,
}))
vi.mock("@/utils/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/utils/message", () => ({
sendMessage: mockSendMessage,
}))
const intersectionObservers: MockIntersectionObserver[] = []
class MockIntersectionObserver {
observe = vi.fn((target: Element) => {
this.targets.add(target)
})
unobserve = vi.fn((target: Element) => {
this.targets.delete(target)
})
disconnect = vi.fn(() => {
this.targets.clear()
})
private readonly targets = new Set<Element>()
constructor(
private readonly callback: IntersectionObserverCallback,
_options?: IntersectionObserverInit,
) {
intersectionObservers.push(this)
}
async triggerIntersect(target: Element): Promise<void> {
await this.callback([{
isIntersecting: true,
target,
} as IntersectionObserverEntry], this as unknown as IntersectionObserver)
}
}
async function flushDomUpdates(): Promise<void> {
await Promise.resolve()
await new Promise(resolve => setTimeout(resolve, 0))
await Promise.resolve()
}
function deepQueryTopLevelSelectorImpl(
root: Document | ShadowRoot | HTMLElement,
selectorFn: (element: HTMLElement) => boolean,
): HTMLElement[] {
if (root instanceof Document) {
return root.body ? deepQueryTopLevelSelectorImpl(root.body, selectorFn) : []
}
if (root instanceof HTMLElement && selectorFn(root)) {
return [root]
}
const result: HTMLElement[] = []
if (root instanceof HTMLElement && root.shadowRoot) {
result.push(...deepQueryTopLevelSelectorImpl(root.shadowRoot, selectorFn))
}
for (const child of root.children) {
if (child instanceof HTMLElement) {
result.push(...deepQueryTopLevelSelectorImpl(child, selectorFn))
}
}
return result
}
function isBlockedForTraversal(element: HTMLElement): boolean {
return Boolean(element.hidden)
|| element.getAttribute("aria-hidden") === "true"
|| element.classList.contains("closed")
}
function walkAndLabelVisibleParagraphs(element: HTMLElement, walkId: string) {
if (isBlockedForTraversal(element)) {
return {
forceBlock: false,
isInlineNode: false,
}
}
element.setAttribute("data-read-frog-walked", walkId)
for (const child of element.children) {
if (child instanceof HTMLElement) {
walkAndLabelVisibleParagraphs(child, walkId)
}
}
if (element.tagName === "P" && element.textContent?.trim()) {
element.setAttribute("data-read-frog-paragraph", "")
}
return {
forceBlock: false,
isInlineNode: false,
}
}
describe("pageTranslationManager mutation re-walk", () => {
beforeEach(() => {
vi.clearAllMocks()
intersectionObservers.length = 0
document.head.innerHTML = ""
document.body.innerHTML = ""
document.title = ""
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver)
mockGetDetectedCodeFromStorage.mockResolvedValue("eng")
mockGetLocalConfig.mockResolvedValue(DEFAULT_CONFIG)
mockGetOrCreateWebPageContext.mockResolvedValue({
url: window.location.href,
webTitle: "",
webContent: "",
})
mockHasNoWalkAncestor.mockReturnValue(false)
mockIsDontWalkIntoButTranslateAsChildElement.mockReturnValue(false)
mockIsDontWalkIntoAndDontTranslateAsChildElement.mockImplementation((element: HTMLElement) => isBlockedForTraversal(element))
mockDeepQueryTopLevelSelector.mockImplementation(deepQueryTopLevelSelectorImpl)
mockWalkAndLabelElement.mockImplementation((element: HTMLElement, walkId: string) => walkAndLabelVisibleParagraphs(element, walkId))
mockTranslateTextForPageTitle.mockResolvedValue("")
mockValidateTranslationConfigAndToast.mockReturnValue(true)
mockSendMessage.mockResolvedValue(undefined)
})
it("observes and translates hidden accordion content after it becomes visible", async () => {
document.body.innerHTML = `
<section id="accordion" hidden>
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.removeAttribute("hidden")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
it("observes and translates aria-hidden accordion content after it becomes visible", async () => {
document.body.innerHTML = `
<section id="accordion" aria-hidden="true">
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.setAttribute("aria-hidden", "false")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
it("keeps style/class based re-walk behavior for existing hidden panels", async () => {
document.body.innerHTML = `
<section id="accordion" class="closed">
<p id="panel">Accordion body</p>
</section>
`
const manager = new PageTranslationManager()
await manager.start()
await flushDomUpdates()
const observer = intersectionObservers[0]
const accordion = document.getElementById("accordion") as HTMLElement
const panel = document.getElementById("panel") as HTMLElement
expect(observer.observe).not.toHaveBeenCalled()
accordion.classList.remove("closed")
await flushDomUpdates()
expect(observer.observe).toHaveBeenCalledWith(panel)
await observer.triggerIntersect(panel)
await flushDomUpdates()
expect(mockTranslateWalkedElement).toHaveBeenCalledWith(panel, "walk-id", DEFAULT_CONFIG)
manager.stop()
})
})

View file

@ -38,7 +38,6 @@ vi.mock("@/utils/config/storage", () => ({
vi.mock("@/utils/host/dom/filter", () => ({
hasNoWalkAncestor: vi.fn().mockReturnValue(false),
isDontWalkIntoAndDontTranslateAsChildElement: vi.fn().mockReturnValue(false),
isDontWalkIntoButTranslateAsChildElement: vi.fn().mockReturnValue(false),
isHTMLElement: (node: unknown) => node instanceof HTMLElement,
}))

View file

@ -1,5 +1,4 @@
import type { FeatureUsageContext } from "@/types/analytics"
import type { Config } from "@/types/config/config"
import { ANALYTICS_FEATURE, ANALYTICS_SURFACE } from "@/types/analytics"
import { isLLMProviderConfig } from "@/types/config/provider"
import { createFeatureUsageContext, trackFeatureUsed } from "@/utils/analytics"
@ -8,7 +7,7 @@ import { getLocalConfig } from "@/utils/config/storage"
import { CONTENT_WRAPPER_CLASS } from "@/utils/constants/dom-labels"
import { resolveProviderConfig } from "@/utils/constants/feature-providers"
import { getRandomUUID } from "@/utils/crypto-polyfill"
import { hasNoWalkAncestor, isDontWalkIntoAndDontTranslateAsChildElement, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { hasNoWalkAncestor, isDontWalkIntoButTranslateAsChildElement, isHTMLElement } from "@/utils/host/dom/filter"
import { deepQueryTopLevelSelector } from "@/utils/host/dom/find"
import { walkAndLabelElement } from "@/utils/host/dom/traversal"
import { removeAllTranslatedWrapperNodes, translateWalkedElement } from "@/utils/host/translate/node-manipulation"
@ -60,7 +59,7 @@ export class PageTranslationManager implements IPageTranslationManager {
private mutationObservers: MutationObserver[] = []
private walkId: string | null = null
private intersectionOptions: IntersectionObserverInit
private walkBlockedElementsCache = new WeakSet<HTMLElement>()
private dontWalkIntoElementsCache = new WeakSet<HTMLElement>()
private titleObserver: MutationObserver | null = null
private lastSourceTitle: string | null = null
private lastAppliedTranslatedTitle: string | null = null
@ -154,8 +153,8 @@ export class PageTranslationManager implements IPageTranslationManager {
}, this.intersectionOptions)
// Initialize walkability state for existing elements
this.addWalkBlockedElements(document.body, config)
await this.observerTopLevelParagraphs(document.body, config)
this.addDontWalkIntoElements(document.body)
await this.observerTopLevelParagraphs(document.body)
// Start observing mutations from document.body and all shadow roots
this.observeMutations(document.body)
@ -190,7 +189,7 @@ export class PageTranslationManager implements IPageTranslationManager {
this.isPageTranslating = false
this.walkId = null
this.walkBlockedElementsCache = new WeakSet()
this.dontWalkIntoElementsCache = new WeakSet()
this.stopDocumentTitleTracking()
if (this.intersectionObserver) {
@ -387,12 +386,12 @@ export class PageTranslationManager implements IPageTranslationManager {
}
}
private async observerTopLevelParagraphs(container: HTMLElement, existingConfig?: Config): Promise<void> {
private async observerTopLevelParagraphs(container: HTMLElement): Promise<void> {
const observer = this.intersectionObserver
if (!this.walkId || !observer)
return
const config = existingConfig ?? await getLocalConfig()
const config = await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
@ -455,39 +454,33 @@ export class PageTranslationManager implements IPageTranslationManager {
}
/**
* Track the same blocked states that the traversal skips, so hidden accordion
* panels can be re-walked when the site reveals an existing subtree.
* Handle style/class attribute changes and only trigger observation
* when element transitions from "don't walk into" to "walkable"
*/
private isWalkBlockedElement(element: HTMLElement, config: Config): boolean {
return isDontWalkIntoButTranslateAsChildElement(element)
|| isDontWalkIntoAndDontTranslateAsChildElement(element, config)
}
/**
* Handle attribute changes and only trigger observation
* when element transitions from blocked to walkable.
*/
private didChangeToWalkable(element: HTMLElement, config: Config): boolean {
const wasWalkBlocked = this.walkBlockedElementsCache.has(element)
const isWalkBlockedNow = this.isWalkBlockedElement(element, config)
private didChangeToWalkable(element: HTMLElement): boolean {
const wasDontWalkInto = this.dontWalkIntoElementsCache.has(element)
const isDontWalkIntoNow = isDontWalkIntoButTranslateAsChildElement(element)
// Update cache with current state
if (isWalkBlockedNow) {
this.walkBlockedElementsCache.add(element)
if (isDontWalkIntoNow) {
this.dontWalkIntoElementsCache.add(element)
}
else {
this.walkBlockedElementsCache.delete(element)
this.dontWalkIntoElementsCache.delete(element)
}
return wasWalkBlocked === true && isWalkBlockedNow === false
// Only trigger observation if element transitioned from "don't walk into" to "walkable"
// wasDontWalkInto === true means it was previously not walkable
// isDontWalkIntoNow === false means it's now walkable
return wasDontWalkInto === true && isDontWalkIntoNow === false
}
/**
* Initialize walkability state for an element and its descendants
*/
private addWalkBlockedElements(element: HTMLElement, config: Config): void {
const walkBlockedElements = deepQueryTopLevelSelector(element, el => this.isWalkBlockedElement(el, config))
walkBlockedElements.forEach(el => this.walkBlockedElementsCache.add(el))
private addDontWalkIntoElements(element: HTMLElement): void {
const dontWalkIntoElements = deepQueryTopLevelSelector(element, isDontWalkIntoButTranslateAsChildElement)
dontWalkIntoElements.forEach(el => this.dontWalkIntoElementsCache.add(el))
}
/**
@ -495,54 +488,39 @@ export class PageTranslationManager implements IPageTranslationManager {
*/
private observeMutations(container: HTMLElement): void {
const mutationObserver = new MutationObserver((records) => {
void this.handleMutationRecords(records)
for (const rec of records) {
if (rec.type === "childList") {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addDontWalkIntoElements(node)
void this.observerTopLevelParagraphs(node)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (
rec.type === "attributes"
&& (rec.attributeName === "style" || rec.attributeName === "class")
) {
const el = rec.target
if (isHTMLElement(el) && this.didChangeToWalkable(el)) {
void this.observerTopLevelParagraphs(el)
}
}
}
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class", "hidden", "aria-hidden"],
attributeFilter: ["style", "class"],
})
this.mutationObservers.push(mutationObserver)
this.observeIsolatedDescendantsMutations(container)
}
private async handleMutationRecords(records: MutationRecord[]): Promise<void> {
const config = await getLocalConfig()
if (!config) {
logger.error("Global config is not initialized")
return
}
for (const rec of records) {
if (rec.type === "childList") {
rec.addedNodes.forEach((node) => {
if (isHTMLElement(node)) {
this.addWalkBlockedElements(node, config)
void this.observerTopLevelParagraphs(node, config)
this.observeIsolatedDescendantsMutations(node)
}
})
}
else if (this.isWalkabilityAttributeMutation(rec)) {
const el = rec.target
if (isHTMLElement(el) && this.didChangeToWalkable(el, config)) {
void this.observerTopLevelParagraphs(el, config)
}
}
}
}
private isWalkabilityAttributeMutation(record: MutationRecord): boolean {
return record.type === "attributes"
&& (record.attributeName === "style"
|| record.attributeName === "class"
|| record.attributeName === "hidden"
|| record.attributeName === "aria-hidden")
}
/**
* Recursively find and observe shadow roots and iframes in an element and its descendants
* These can't be find as top level paragraph elements because isolated shadow roots and iframes are not

View file

@ -2,8 +2,7 @@ import { defineContentScript } from "#imports"
import { injectPlayerApi } from "./inject-player-api"
export default defineContentScript({
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*"],
allFrames: true,
matches: ["*://*.youtube.com/*"],
world: "MAIN",
runAt: "document_start",
main() {

View file

@ -37,15 +37,10 @@ declare global {
function findYoutubePlayer(): YouTubePlayer | null {
return document.querySelector(
".html5-video-player.playing-mode, .html5-video-player.paused-mode",
) ?? document.querySelector(".html5-video-player")
)
}
export function injectPlayerApi(): void {
if ((window as any).__READ_FROG_INTERCEPTOR_INJECTED__) {
return
}
;(window as any).__READ_FROG_INTERCEPTOR_INJECTED__ = true
setupTimedtextObserver()
window.addEventListener("message", handleMessage)
}

View file

@ -22,23 +22,17 @@ vi.mock("@/components/ui/json-code-editor", () => ({
JSONCodeEditor: ({
value,
onChange,
onBlur,
onFocus,
placeholder,
}: {
value?: string
onChange?: (value: string) => void
onBlur?: () => void
onFocus?: () => void
placeholder?: string
}) => (
<textarea
aria-label="provider-options-editor"
value={value}
placeholder={placeholder}
onBlur={onBlur}
onChange={event => onChange?.(event.target.value)}
onFocus={onFocus}
/>
),
}))
@ -59,22 +53,16 @@ const baseProviderConfig: APIProviderConfig = {
function ProviderOptionsFieldHarness({
initialConfig,
externalProviderOptions,
submitDelayMs = 0,
}: {
initialConfig: APIProviderConfig
externalProviderOptions?: Record<string, unknown>
submitDelayMs?: number
}) {
const [providerConfig, setProviderConfig] = useState(initialConfig)
const form = useAppForm({
...formOpts,
defaultValues: providerConfig,
onSubmit: async ({ value }) => {
if (submitDelayMs > 0) {
await new Promise(resolve => window.setTimeout(resolve, submitDelayMs))
}
setProviderConfig(submitDelayMs > 0 ? structuredClone(value) : value)
setProviderConfig(value)
},
})
@ -117,7 +105,6 @@ describe("providerOptionsField", () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
await act(async () => {
@ -128,29 +115,6 @@ describe("providerOptionsField", () => {
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"minimal\"}")
})
it("keeps focused draft edits when a delayed autosave echo arrives", async () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} submitDelayMs={100} />)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"minimal\"}" } })
await act(async () => {
vi.advanceTimersByTime(500)
await Promise.resolve()
})
fireEvent.change(editor, { target: { value: "{\"reasoningEffort\":\"low\"}" } })
await act(async () => {
vi.advanceTimersByTime(100)
await Promise.resolve()
await Promise.resolve()
})
expect(screen.getByLabelText("provider-options-editor")).toHaveValue("{\"reasoningEffort\":\"low\"}")
})
it("shows the matched recommended provider options as the placeholder when the value is empty", () => {
render(<ProviderOptionsFieldHarness initialConfig={baseProviderConfig} />)
@ -216,9 +180,7 @@ describe("providerOptionsField", () => {
)
const editor = screen.getByLabelText("provider-options-editor")
fireEvent.focus(editor)
fireEvent.change(editor, { target: { value: "{" } })
fireEvent.blur(editor)
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "apply-external" }))
await Promise.resolve()

View file

@ -39,7 +39,6 @@ export const ProviderOptionsField = withForm({
const [jsonInput, setJsonInput] = useState(() => externalJson)
const lastCommittedJsonRef = useRef(externalJson)
const pendingEditorCommitRef = useRef(false)
const editorFocusedRef = useRef(false)
const syncJsonInput = useEffectEvent((nextJson: string) => {
// eslint-disable-next-line react/set-state-in-effect
@ -56,18 +55,6 @@ export const ProviderOptionsField = withForm({
return jsonInput
})
const handleJsonInputChange = useCallback((nextJson: string) => {
setJsonInput(nextJson)
}, [])
const handleEditorFocus = useCallback(() => {
editorFocusedRef.current = true
}, [])
const handleEditorBlur = useCallback(() => {
editorFocusedRef.current = false
}, [])
useEffect(() => {
resetSyncStateForProvider()
}, [providerConfig.id])
@ -79,15 +66,9 @@ export const ProviderOptionsField = withForm({
}
pendingEditorCommitRef.current = false
const currentJsonInput = readJsonInput()
if (editorFocusedRef.current && currentJsonInput !== lastCommittedJsonRef.current) {
return
}
lastCommittedJsonRef.current = externalJson
if (currentJsonInput !== externalJson) {
if (readJsonInput() !== externalJson) {
syncJsonInput(externalJson)
}
}, [providerConfig.providerOptions, externalJson])
@ -146,9 +127,7 @@ export const ProviderOptionsField = withForm({
</FieldLabel>
<JSONCodeEditor
value={jsonInput}
onChange={handleJsonInputChange}
onFocus={handleEditorFocus}
onBlur={handleEditorBlur}
onChange={setJsonInput}
placeholder={placeholderText}
hasError={!!jsonError}
height="150px"

View file

@ -94,7 +94,7 @@ function DialogContent({ onResolved, onCancelled }: DialogContentProps) {
const canConfirm = status.isValid && !isConfirming
return (
<AlertDialogContent className="data-[size=default]:max-w-[calc(100vw-2rem)] data-[size=default]:md:max-w-2xl data-[size=default]:lg:max-w-4xl data-[size=default]:xl:max-w-5xl max-h-[90vh] flex flex-col overflow-hidden">
<AlertDialogContent className="w-[min(80rem,calc(100vw-2rem))] max-w-none max-h-[90vh] flex flex-col overflow-hidden">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<Icon icon="mdi:alert" className="size-5 text-yellow-500" />

View file

@ -1,4 +1,3 @@
import type { FloatingButtonClickAction as FloatingButtonClickActionValue } from "@/types/config/floating-button"
import { i18n } from "#imports"
import { useAtom } from "jotai"
import {
@ -9,14 +8,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/base-ui/select"
import { floatingButtonClickActionSchema } from "@/types/config/floating-button"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { ConfigCard } from "../../components/config-card"
const items = [
{ value: "panel", label: i18n.t("options.floatingButtonAndToolbar.floatingButton.clickAction.panel") },
{ value: "translate", label: i18n.t("options.floatingButtonAndToolbar.floatingButton.clickAction.translate") },
] satisfies Array<{ value: FloatingButtonClickActionValue, label: string }>
]
export function FloatingButtonClickAction() {
const [floatingButton, setFloatingButton] = useAtom(
@ -34,10 +32,9 @@ export function FloatingButtonClickAction() {
items={items}
value={floatingButton.clickAction}
onValueChange={(value) => {
const parsedValue = floatingButtonClickActionSchema.safeParse(value)
if (!parsedValue.success)
if (!value)
return
void setFloatingButton({ ...floatingButton, clickAction: parsedValue.data })
void setFloatingButton({ ...floatingButton, clickAction: value })
}}
>
<SelectTrigger className="w-[180px]">

View file

@ -1,10 +1,12 @@
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

@ -1,14 +1,9 @@
// @vitest-environment jsdom
import type { FloatingButtonConfig } from "@/types/config/floating-button"
import { act, fireEvent, render, screen } from "@testing-library/react"
import { atom, createStore, Provider } from "jotai"
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { sendMessage } from "@/utils/message"
import { fireEvent, render, screen } from "@testing-library/react"
import { atom } from "jotai"
import { beforeAll, describe, expect, it, vi } from "vitest"
import FloatingButton from ".."
const toastInfoMock = vi.fn()
vi.mock("#imports", () => ({
browser: {
runtime: {
@ -20,32 +15,18 @@ vi.mock("#imports", () => ({
},
}))
vi.mock("@/utils/atoms/config", () => {
const floatingButtonBaseAtom = atom<FloatingButtonConfig>({
enabled: true,
position: 0.66,
side: "right",
clickAction: "panel",
disabledFloatingButtonPatterns: [],
locked: false,
})
const floatingButtonAtom = atom(
get => get(floatingButtonBaseAtom),
(get, set, patch: Partial<FloatingButtonConfig>) => {
set(floatingButtonBaseAtom, {
...get(floatingButtonBaseAtom),
...patch,
})
},
)
return {
configFieldsAtomMap: {
floatingButton: floatingButtonAtom,
sideContent: atom({ width: 360 }),
},
}
})
vi.mock("@/utils/atoms/config", () => ({
configFieldsAtomMap: {
floatingButton: atom({
enabled: true,
position: 0.66,
clickAction: "panel",
disabledFloatingButtonPatterns: [],
locked: false,
}),
sideContent: atom({ width: 360 }),
},
}))
vi.mock("../../../atoms", () => ({
enablePageTranslationAtom: atom({ enabled: false }),
@ -57,14 +38,20 @@ vi.mock("../../../index", () => ({
shadowWrapper: document.body,
}))
vi.mock("@/utils/message", () => ({
sendMessage: vi.fn(),
vi.mock("../translate-button", () => ({
default: ({ className }: { className?: string, expanded?: boolean }) => (
<div data-testid="translate-button" className={className} />
),
}))
vi.mock("sonner", () => ({
toast: {
info: (...args: unknown[]) => toastInfoMock(...args),
},
vi.mock("../components/hidden-button", () => ({
default: ({ className, onClick }: { className?: string, onClick: () => void, expanded?: boolean }) => (
<button type="button" data-testid="hidden-button" className={className} onClick={onClick} />
),
}))
vi.mock("@/utils/message", () => ({
sendMessage: vi.fn(),
}))
beforeAll(() => {
@ -77,87 +64,24 @@ beforeAll(() => {
vi.stubGlobal("ResizeObserver", ResizeObserverMock)
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
vi.mocked(sendMessage).mockReset()
toastInfoMock.mockReset()
setViewport(1024, 768)
})
function setViewport(width: number, height: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
})
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: height,
})
}
function renderFloatingButton(
floatingButtonOverrides: Partial<FloatingButtonConfig> = {},
) {
const store = createStore()
void store.set(configFieldsAtomMap.floatingButton, floatingButtonOverrides)
return {
store,
...render(
<Provider store={store}>
<FloatingButton />
</Provider>,
),
}
}
function getMainButton() {
return screen.getByTestId("floating-main-button")
}
function getFloatingButtonConfig(store: ReturnType<typeof createStore>) {
return store.get(configFieldsAtomMap.floatingButton)
}
function mockRect(element: Element, rect: Partial<DOMRect>) {
const left = rect.left ?? 0
const top = rect.top ?? 0
const width = rect.width ?? 0
const height = rect.height ?? 0
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
x: left,
y: top,
left,
top,
width,
height,
right: rect.right ?? left + width,
bottom: rect.bottom ?? top + height,
toJSON: () => {},
} as DOMRect)
}
describe("floatingButton controls", () => {
it("shows the close trigger only after entering the main floating button", () => {
renderFloatingButton()
render(<FloatingButton />)
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const mainButton = getMainButton()
const mainButton = screen.getByRole("img").parentElement
expect(mainButton).toHaveClass("transition-transform")
expect(mainButton).toHaveClass("duration-300")
expect(closeTrigger).toHaveClass("-top-1")
expect(closeTrigger).toHaveClass("left-0")
expect(closeTrigger).toHaveClass("invisible")
expect(closeTrigger).toHaveClass("pointer-events-none")
expect(closeTrigger).toHaveClass("text-neutral-300")
expect(closeTrigger).toHaveClass("text-neutral-400")
expect(closeTrigger).toHaveClass("hover:scale-110")
expect(closeTrigger).toHaveClass("active:scale-90")
expect(closeTrigger).toHaveClass("hover:text-neutral-500")
expect(closeTrigger).toHaveClass("active:text-neutral-500")
expect(closeTrigger).toHaveClass("hover:text-neutral-600")
expect(closeTrigger).toHaveClass("active:text-neutral-600")
fireEvent.mouseEnter(mainButton)
fireEvent.mouseEnter(mainButton!)
expect(closeTrigger).toHaveClass("visible")
expect(closeTrigger).toHaveClass("pointer-events-auto")
@ -165,24 +89,24 @@ describe("floatingButton controls", () => {
})
it("renders a lock trigger at the lower-left corner and keeps controls expanded after entering the main button", () => {
renderFloatingButton()
render(<FloatingButton />)
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
const mainButton = screen.getByRole("img").parentElement
const floatingButtonContainer = mainButton?.parentElement?.parentElement
expect(lockTrigger).toHaveClass("left-0")
expect(lockTrigger).toHaveClass("-bottom-1")
expect(lockTrigger).toHaveClass("invisible")
expect(lockTrigger).toHaveClass("pointer-events-none")
expect(lockTrigger).toHaveClass("text-neutral-300")
expect(lockTrigger).toHaveClass("text-neutral-400")
expect(lockTrigger).toHaveClass("hover:scale-110")
expect(lockTrigger).toHaveClass("active:scale-90")
expect(lockTrigger).toHaveClass("hover:text-neutral-500")
expect(lockTrigger).toHaveClass("active:text-neutral-500")
expect(lockTrigger).toHaveClass("hover:text-neutral-600")
expect(lockTrigger).toHaveClass("active:text-neutral-600")
expect(mainButton).toHaveClass("translate-x-6")
fireEvent.mouseEnter(mainButton)
fireEvent.mouseEnter(mainButton!)
expect(lockTrigger).toHaveClass("visible")
expect(lockTrigger).toHaveClass("pointer-events-auto")
@ -193,199 +117,33 @@ describe("floatingButton controls", () => {
const unlockTrigger = screen.getByRole("button", { name: "Unlock floating button" })
expect(unlockTrigger).toHaveClass("text-neutral-300")
expect(unlockTrigger).toHaveClass("text-neutral-400")
expect(unlockTrigger).toHaveClass("-left-6")
expect(mainButton).toHaveClass("translate-x-0")
expect(mainButton).toHaveClass("opacity-100")
expect(mainButton).not.toHaveClass("translate-x-6")
fireEvent.mouseLeave(floatingButtonContainer)
fireEvent.mouseLeave(floatingButtonContainer!)
expect(mainButton).toHaveClass("translate-x-0")
expect(mainButton).toHaveClass("opacity-60")
fireEvent.mouseEnter(mainButton)
fireEvent.mouseEnter(mainButton!)
expect(mainButton).toHaveClass("opacity-100")
})
it("forces the close trigger visible while the dropdown is open", () => {
renderFloatingButton()
render(<FloatingButton />)
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const mainButton = getMainButton()
const mainButton = screen.getByRole("img").parentElement
fireEvent.mouseEnter(mainButton)
fireEvent.mouseEnter(mainButton!)
fireEvent.click(closeTrigger)
expect(closeTrigger).toHaveClass("visible")
expect(closeTrigger).toHaveClass("pointer-events-auto")
expect(screen.getByText("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")).toBeInTheDocument()
})
it("toggles the browser side panel on a normal panel click", () => {
vi.useFakeTimers()
renderFloatingButton({ clickAction: "panel" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).toHaveBeenCalledWith("toggleSidePanel", undefined)
})
it("shows a Firefox sidebar help link when the browser requires an extension user action", async () => {
vi.useFakeTimers()
vi.mocked(sendMessage).mockResolvedValue({
ok: false,
reason: "requires-extension-user-action",
})
renderFloatingButton({ clickAction: "panel" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
await act(async () => {
await Promise.resolve()
})
const toastContent = toastInfoMock.mock.calls[0]?.[0]
expect(toastContent).toBeDefined()
render(<>{toastContent}</>)
expect(screen.getByText("sidePanel.firefoxUserActionHint")).toBeInTheDocument()
const link = screen.getByRole("link", { name: "sidePanel.firefoxUserActionHelpText" })
expect(link).toHaveAttribute(
"href",
"sidePanel.firefoxUserActionHelpUrl",
)
expect(link).toHaveAttribute("target", "_blank")
expect(link).toHaveAttribute("rel", "noopener noreferrer")
})
it("keeps translate as a normal click action", () => {
vi.useFakeTimers()
renderFloatingButton({ clickAction: "translate" })
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
vi.advanceTimersByTime(349)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).toHaveBeenCalledWith(
"tryToSetEnablePageTranslationOnContentScript",
expect.objectContaining({ enabled: true }),
)
})
it("turns the frog into the only visible control after a long press", () => {
vi.useFakeTimers()
renderFloatingButton()
const mainButton = getMainButton()
expect(screen.getAllByRole("button")).toHaveLength(4)
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
act(() => {
vi.advanceTimersByTime(350)
})
expect(mainButton).toHaveClass("rounded-full")
expect(screen.queryAllByRole("button")).toHaveLength(0)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
expect(sendMessage).not.toHaveBeenCalled()
})
it("starts dragging before the long-press delay after enough pointer movement", () => {
vi.useFakeTimers()
renderFloatingButton()
const mainButton = getMainButton()
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 500 })
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
expect(mainButton).toHaveClass("rounded-full")
expect(screen.queryAllByRole("button")).toHaveLength(0)
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 908, clientY: 500 })
expect(sendMessage).not.toHaveBeenCalled()
})
it("persists the left side and vertical position after dragging to the left half", () => {
vi.useFakeTimers()
setViewport(1000, 1000)
const { store } = renderFloatingButton({ position: 0.6, side: "right" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
mockRect(floatingButtonContainer, { left: 956, top: 600, width: 44, height: 120 })
mockRect(mainButton, { left: 956, top: 640, width: 44, height: 40 })
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 978, clientY: 660 })
act(() => {
vi.advanceTimersByTime(350)
})
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 120, clientY: 520 })
expect(getFloatingButtonConfig(store).side).toBe("left")
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
})
it("persists the right side and vertical position after dragging to the right half", () => {
vi.useFakeTimers()
setViewport(1000, 1000)
const { store } = renderFloatingButton({ position: 0.6, side: "left" })
const mainButton = getMainButton()
const floatingButtonContainer = screen.getByTestId("floating-button-container")
mockRect(floatingButtonContainer, { left: 0, top: 600, width: 44, height: 120 })
mockRect(mainButton, { left: 0, top: 640, width: 44, height: 40 })
fireEvent.pointerDown(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 22, clientY: 660 })
act(() => {
vi.advanceTimersByTime(350)
})
fireEvent.pointerMove(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
fireEvent.pointerUp(mainButton, { pointerId: 1, pointerType: "mouse", button: 0, clientX: 900, clientY: 520 })
expect(getFloatingButtonConfig(store).side).toBe("right")
expect(getFloatingButtonConfig(store).position).toBeCloseTo(0.46)
})
it("mirrors the controls when attached to the left edge", () => {
renderFloatingButton({ side: "left" })
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
const mainButton = getMainButton()
const hiddenButtons = screen.getAllByRole("button").filter(button => (
button !== closeTrigger && button !== lockTrigger
))
expect(mainButton).toHaveClass("rounded-r-full")
expect(mainButton).toHaveClass("-translate-x-6")
expect(closeTrigger).toHaveClass("right-0")
expect(lockTrigger).toHaveClass("right-0")
for (const hiddenButton of hiddenButtons) {
expect(hiddenButton).toHaveClass("-translate-x-12")
}
fireEvent.mouseEnter(mainButton)
expect(mainButton).toHaveClass("translate-x-0")
expect(closeTrigger).toHaveClass("-right-6")
expect(lockTrigger).toHaveClass("-right-6")
for (const hiddenButton of hiddenButtons) {
expect(hiddenButton).toHaveClass("translate-x-0")
}
})
})

View file

@ -1,4 +1,3 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { cn } from "@/utils/styles/utils"
export default function HiddenButton({
@ -6,25 +5,20 @@ export default function HiddenButton({
onClick,
children,
className,
side = "right",
expanded = false,
}: {
icon: React.ReactNode
onClick: () => void
children?: React.ReactNode
className?: string
side?: FloatingButtonSide
expanded?: boolean
}) {
return (
<button
type="button"
className={cn(
"border-border cursor-pointer rounded-full border bg-white shadow-lg p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800",
side === "right" ? "mr-2" : "ml-2",
expanded
? "translate-x-0"
: side === "right" ? "translate-x-12" : "-translate-x-12",
"border-border mr-2 cursor-pointer rounded-full border bg-white shadow-lg p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800",
expanded ? "translate-x-0" : "translate-x-12",
className,
)}
onClick={onClick}

View file

@ -1,9 +1,7 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { browser, i18n } from "#imports"
import { IconLock, IconLockOpen, IconSettings, IconX } from "@tabler/icons-react"
import { useAtom, useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import readFrogLogo from "@/assets/icons/read-frog.png?url&no-inline"
import {
DropdownMenu,
@ -18,304 +16,134 @@ 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 } from "../../atoms"
import { enablePageTranslationAtom, isDraggingButtonAtom, isSideOpenAtom } from "../../atoms"
import { shadowWrapper } from "../../index"
import HiddenButton from "./components/hidden-button"
import TranslateButton from "./translate-button"
const readFrogLogoUrl = new URL(readFrogLogo, browser.runtime.getURL("/")).href
const LONG_PRESS_DELAY_MS = 350
const DRAG_START_DISTANCE_PX = 6
const MIN_FLOATING_CONTAINER_TOP_PX = 30
const FLOATING_CONTAINER_BOTTOM_CLEARANCE_PX = 200
interface DragPoint {
x: number
y: number
}
interface PendingDragState {
pointerId: number
startClientX: number
startClientY: number
currentClientX: number
currentClientY: number
pointerOffsetX: number
pointerOffsetY: number
mainOffsetY: number
buttonWidth: number
buttonHeight: number
hasActiveDrag: boolean
longPressTimerId: number
}
const floatingButtonControlClassName = cn(
"absolute invisible cursor-pointer pointer-events-none flex size-6 items-center justify-center",
"text-neutral-300 transition-[color,left,right,transform] duration-300 hover:scale-110 hover:text-neutral-500 active:scale-90 active:text-neutral-500",
"dark:text-neutral-700 dark:hover:text-neutral-500 dark:active:text-neutral-500",
"text-neutral-400 transition-[color,left,transform] duration-300 hover:scale-110 hover:text-neutral-600 active:scale-90 active:text-neutral-600",
"dark:text-neutral-600 dark:hover:text-neutral-400 dark:active:text-neutral-400",
)
const floatingButtonControlOffsetClassNames = {
right: {
collapsed: "left-0",
expanded: "-left-6",
},
left: {
collapsed: "right-0",
expanded: "-right-6",
},
} satisfies Record<FloatingButtonSide, { collapsed: string, expanded: string }>
function FirefoxSidebarHelpToast() {
return (
<span>
{i18n.t("sidePanel.firefoxUserActionHint")}
{" "}
<a
href={i18n.t("sidePanel.firefoxUserActionHelpUrl")}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
{i18n.t("sidePanel.firefoxUserActionHelpText")}
</a>
</span>
)
}
function getFloatingButtonSide(side: string | undefined): FloatingButtonSide {
return side === "left" ? "left" : "right"
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value))
}
function getPointerDistance(startX: number, startY: number, currentX: number, currentY: number) {
return Math.hypot(currentX - startX, currentY - startY)
}
function getDragPreviewPosition(pendingDrag: PendingDragState): DragPoint {
return {
x: clamp(
pendingDrag.currentClientX - pendingDrag.pointerOffsetX,
0,
Math.max(0, window.innerWidth - pendingDrag.buttonWidth),
),
y: clamp(
pendingDrag.currentClientY - pendingDrag.pointerOffsetY,
0,
Math.max(0, window.innerHeight - pendingDrag.buttonHeight),
),
}
}
function getNormalizedFloatingContainerTop(mainButtonTop: number, mainOffsetY: number) {
const viewportHeight = Math.max(1, window.innerHeight)
const maxTop = Math.max(
MIN_FLOATING_CONTAINER_TOP_PX,
viewportHeight - FLOATING_CONTAINER_BOTTOM_CLEARANCE_PX,
)
const containerTop = clamp(
mainButtonTop - mainOffsetY,
MIN_FLOATING_CONTAINER_TOP_PX,
maxTop,
)
return containerTop / viewportHeight
collapsed: "left-0",
expanded: "-left-6",
}
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 [isHitAreaExpanded, setIsHitAreaExpanded] = useState(false)
const [dragPreviewPosition, setDragPreviewPosition] = useState<DragPoint | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
const mainButtonRef = useRef<HTMLDivElement | null>(null)
const pendingDragRef = useRef<PendingDragState | null>(null)
const lastDragPreviewRef = useRef<DragPoint | null>(null)
const [dragPosition, setDragPosition] = useState<number | null>(null)
const initialClientYRef = useRef<number | null>(null)
const isFloatingButtonLocked = floatingButton.locked
const floatingButtonSide = getFloatingButtonSide(floatingButton.side)
const isFloatingButtonExpanded = isHitAreaExpanded || isDropdownOpen
const isFloatingButtonExpanded = isHitAreaExpanded || isDraggingButton || isSideOpen || isDropdownOpen
const isMainButtonAttached = isFloatingButtonLocked || isFloatingButtonExpanded
// 按钮拖动处理
useEffect(() => {
if (!isDraggingButton)
const initialClientY = initialClientYRef.current
if (!isDraggingButton || !initialClientY || !floatingButton)
return
const previousUserSelect = document.body.style.userSelect
const previousCursor = document.body.style.cursor
const handleMouseMove = (e: MouseEvent) => {
const initialY = floatingButton.position * window.innerHeight
const newY = Math.max(
30,
Math.min(
window.innerHeight - 200,
initialY + e.clientY - initialClientY,
),
)
const newPosition = newY / window.innerHeight
setDragPosition(newPosition)
}
const handleMouseUp = () => {
setIsDraggingButton(false)
}
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = "none"
document.body.style.cursor = "grabbing"
return () => {
document.body.style.userSelect = previousUserSelect
document.body.style.cursor = previousCursor
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
document.body.style.userSelect = ""
}
// eslint-disable-next-line react/exhaustive-deps
}, [isDraggingButton])
// 拖拽结束时写入 storage
useEffect(() => {
return () => {
const pendingDrag = pendingDragRef.current
if (pendingDrag) {
window.clearTimeout(pendingDrag.longPressTimerId)
}
if (!isDraggingButton && dragPosition !== null) {
void setFloatingButton({ position: dragPosition })
// eslint-disable-next-line react/set-state-in-effect
setDragPosition(null)
}
}, [])
}, [isDraggingButton, dragPosition, setFloatingButton])
const handleFloatingButtonClick = () => {
if (floatingButton.clickAction === "translate") {
const nextEnabled = !translationState.enabled
void sendMessage("tryToSetEnablePageTranslationOnContentScript", {
enabled: nextEnabled,
analyticsContext: nextEnabled
? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.FLOATING_BUTTON)
: undefined,
})
return
}
void Promise.resolve(sendMessage("toggleSidePanel", undefined)).then((result) => {
if (result?.ok === false && result.reason === "requires-extension-user-action") {
toast.info(<FirefoxSidebarHelpToast />)
}
})
}
const startActiveDrag = () => {
const pendingDrag = pendingDragRef.current
if (!pendingDrag || pendingDrag.hasActiveDrag)
return
pendingDrag.hasActiveDrag = true
const nextPreviewPosition = getDragPreviewPosition(pendingDrag)
lastDragPreviewRef.current = nextPreviewPosition
setDragPreviewPosition(nextPreviewPosition)
setIsHitAreaExpanded(false)
setIsDropdownOpen(false)
setIsDraggingButton(true)
}
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse" && e.button !== 0)
return
const mainButton = mainButtonRef.current ?? e.currentTarget
const mainButtonRect = mainButton.getBoundingClientRect()
const containerRect = containerRef.current?.getBoundingClientRect()
const mainOffsetY = containerRect
? mainButtonRect.top - containerRect.top
: 0
const handleButtonDragStart = (e: React.MouseEvent) => {
// 记录初始位置,用于后续判断是点击还是拖动
initialClientYRef.current = e.clientY
let hasMoved = false // 标记是否发生了移动
e.preventDefault()
if (typeof e.currentTarget.setPointerCapture === "function") {
e.currentTarget.setPointerCapture(e.pointerId)
}
setIsDraggingButton(true)
pendingDragRef.current = {
pointerId: e.pointerId,
startClientX: e.clientX,
startClientY: e.clientY,
currentClientX: e.clientX,
currentClientY: e.clientY,
pointerOffsetX: e.clientX - mainButtonRect.left,
pointerOffsetY: e.clientY - mainButtonRect.top,
mainOffsetY,
buttonWidth: mainButtonRect.width || 40,
buttonHeight: mainButtonRect.height || 40,
hasActiveDrag: false,
longPressTimerId: window.setTimeout(startActiveDrag, LONG_PRESS_DELAY_MS),
}
}
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const pendingDrag = pendingDragRef.current
if (!pendingDrag || pendingDrag.pointerId !== e.pointerId)
return
pendingDrag.currentClientX = e.clientX
pendingDrag.currentClientY = e.clientY
if (!pendingDrag.hasActiveDrag) {
const pointerDistance = getPointerDistance(
pendingDrag.startClientX,
pendingDrag.startClientY,
e.clientX,
e.clientY,
)
if (pointerDistance > DRAG_START_DISTANCE_PX) {
startActiveDrag()
// 创建一个监听器检测移动
const handleMouseMove = (moveEvent: MouseEvent) => {
const moveDistance = Math.abs(moveEvent.clientY - e.clientY)
// 如果移动距离大于阈值,标记为已移动
if (moveDistance > 5) {
hasMoved = true
}
}
if (pendingDrag.hasActiveDrag) {
const nextPreviewPosition = getDragPreviewPosition(pendingDrag)
lastDragPreviewRef.current = nextPreviewPosition
setDragPreviewPosition(nextPreviewPosition)
}
}
// 在鼠标释放时,只有未移动才触发点击事件
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
const finishPointerInteraction = (
e: React.PointerEvent<HTMLDivElement>,
shouldTriggerClick: boolean,
) => {
const pendingDrag = pendingDragRef.current
if (!pendingDrag || pendingDrag.pointerId !== e.pointerId)
return
window.clearTimeout(pendingDrag.longPressTimerId)
pendingDragRef.current = null
if (typeof e.currentTarget.releasePointerCapture === "function") {
e.currentTarget.releasePointerCapture(e.pointerId)
// 只有未移动过才触发点击
if (!hasMoved) {
if (floatingButton.clickAction === "translate") {
const nextEnabled = !translationState.enabled
void sendMessage("tryToSetEnablePageTranslationOnContentScript", {
enabled: nextEnabled,
analyticsContext: nextEnabled
? createFeatureUsageContext(ANALYTICS_FEATURE.PAGE_TRANSLATION, ANALYTICS_SURFACE.FLOATING_BUTTON)
: undefined,
})
}
else {
setIsSideOpen(o => !o)
}
}
}
if (pendingDrag.hasActiveDrag) {
const finalPreviewPosition = lastDragPreviewRef.current
?? getDragPreviewPosition(pendingDrag)
const finalCenterX = finalPreviewPosition.x + pendingDrag.buttonWidth / 2
const nextSide: FloatingButtonSide = finalCenterX < window.innerWidth / 2
? "left"
: "right"
const nextPosition = getNormalizedFloatingContainerTop(
finalPreviewPosition.y,
pendingDrag.mainOffsetY,
)
lastDragPreviewRef.current = null
setDragPreviewPosition(null)
void setFloatingButton({ position: nextPosition, side: nextSide })
setIsDraggingButton(false)
return
}
lastDragPreviewRef.current = null
setDragPreviewPosition(null)
setIsDraggingButton(false)
if (shouldTriggerClick) {
handleFloatingButtonClick()
}
document.addEventListener("mouseup", handleMouseUp)
document.addEventListener("mousemove", handleMouseMove)
}
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
finishPointerInteraction(e, true)
}
const handlePointerCancel = (e: React.PointerEvent<HTMLDivElement>) => {
finishPointerInteraction(e, false)
}
const attachSideClassName = isDraggingButton || isSideOpen || isDropdownOpen ? "translate-x-0" : ""
const handleMouseEnter = () => {
if (!isDraggingButton) {
setIsHitAreaExpanded(true)
}
setIsHitAreaExpanded(true)
}
const handleMouseLeave = () => {
if (!isDropdownOpen && !isDraggingButton) {
if (!isDropdownOpen) {
setIsHitAreaExpanded(false)
}
}
@ -324,119 +152,72 @@ export default function FloatingButton() {
return null
}
const containerStyle: React.CSSProperties = isDraggingButton && dragPreviewPosition
? {
left: `${dragPreviewPosition.x}px`,
right: "auto",
top: `${dragPreviewPosition.y}px`,
}
: {
left: floatingButtonSide === "left" ? "0px" : undefined,
right: floatingButtonSide === "right"
? "var(--removed-body-scroll-bar-size, 0px)"
: undefined,
top: `${floatingButton.position * 100}vh`,
}
return (
<div
ref={containerRef}
data-testid="floating-button-container"
className={cn(
"fixed z-2147483647 flex flex-col gap-2 print:hidden",
isDraggingButton
? "items-center"
: floatingButtonSide === "right" ? "items-end" : "items-start",
!isDraggingButton && isFloatingButtonExpanded && (
floatingButtonSide === "right" ? "pl-6" : "pr-6"
),
"fixed z-2147483647 flex flex-col items-end gap-2 print:hidden",
isFloatingButtonExpanded && "pl-6",
)}
style={containerStyle}
style={{
right: isSideOpen
? `calc(${sideContent.width}px + var(--removed-body-scroll-bar-size, 0px))`
: "var(--removed-body-scroll-bar-size, 0px)",
top: `${(dragPosition ?? floatingButton.position) * 100}vh`,
}}
onMouseLeave={handleMouseLeave}
>
{!isDraggingButton && (
<TranslateButton
side={floatingButtonSide}
expanded={isFloatingButtonExpanded}
/>
)}
<TranslateButton className={attachSideClassName} expanded={isFloatingButtonExpanded} />
<div className="relative">
<div
ref={mainButtonRef}
data-testid="floating-main-button"
className={cn(
"border-border relative flex h-10 items-center bg-white shadow-lg transition-transform duration-300 dark:bg-neutral-900",
isDraggingButton
? "w-10 touch-none justify-center rounded-full border cursor-grabbing opacity-100"
: floatingButtonSide === "right"
? "w-11 justify-start rounded-l-full border border-r-0"
: "w-11 justify-end rounded-r-full border border-l-0",
!isDraggingButton && (isMainButtonAttached
? "translate-x-0"
: floatingButtonSide === "right" ? "translate-x-6" : "-translate-x-6"),
!isDraggingButton && (isFloatingButtonExpanded ? "opacity-100" : "opacity-60"),
!isDraggingButton && "cursor-pointer",
"border-border relative flex h-10 w-11 items-center rounded-l-full border border-r-0 bg-white shadow-lg transition-transform duration-300 dark:bg-neutral-900",
isMainButtonAttached ? "translate-x-0" : "translate-x-6",
isFloatingButtonExpanded ? "opacity-100" : "opacity-60",
isDraggingButton ? "cursor-move" : "cursor-pointer",
attachSideClassName,
)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onMouseDown={handleButtonDragStart}
onMouseEnter={handleMouseEnter}
>
<img
src={readFrogLogoUrl}
alt={APP_NAME}
className={cn(
"h-8 w-8 rounded-full",
!isDraggingButton && (floatingButtonSide === "right" ? "ml-1" : "mr-1"),
)}
className="ml-1 h-8 w-8 rounded-full"
/>
</div>
{!isDraggingButton && (
<>
<FloatingButtonCloseMenu
expanded={isFloatingButtonExpanded}
side={floatingButtonSide}
onDropdownOpenChange={setIsDropdownOpen}
/>
<FloatingButtonLockControl
expanded={isFloatingButtonExpanded}
side={floatingButtonSide}
/>
</>
)}
</div>
{!isDraggingButton && (
<HiddenButton
side={floatingButtonSide}
<FloatingButtonCloseMenu
expanded={isFloatingButtonExpanded}
icon={<IconSettings className="h-5 w-5" />}
onClick={() => {
void sendMessage("openOptionsPage", undefined)
}}
onDropdownOpenChange={setIsDropdownOpen}
/>
)}
<FloatingButtonLockControl expanded={isFloatingButtonExpanded} />
</div>
<HiddenButton
className={attachSideClassName}
expanded={isFloatingButtonExpanded}
icon={<IconSettings className="h-5 w-5" />}
onClick={() => {
void sendMessage("openOptionsPage", undefined)
}}
/>
</div>
)
}
interface FloatingButtonCloseMenuProps {
expanded: boolean
side: FloatingButtonSide
onDropdownOpenChange: (open: boolean) => void
}
function FloatingButtonCloseMenu({
expanded,
side,
onDropdownOpenChange,
}: FloatingButtonCloseMenuProps) {
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
const [open, setOpen] = useState(false)
const controlOffsetClassName = !floatingButton.locked && !expanded
? floatingButtonControlOffsetClassNames[side].collapsed
: floatingButtonControlOffsetClassNames[side].expanded
? floatingButtonControlOffsetClassNames.collapsed
: floatingButtonControlOffsetClassNames.expanded
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
@ -476,7 +257,7 @@ function FloatingButtonCloseMenu({
>
<IconX className="h-3 w-3" strokeWidth={3} />
</DropdownMenuTrigger>
<DropdownMenuContent container={shadowWrapper} align="start" side={side === "right" ? "left" : "right"} className="z-2147483647 w-fit! whitespace-nowrap">
<DropdownMenuContent container={shadowWrapper} align="start" side="left" className="z-2147483647 w-fit! whitespace-nowrap">
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={handleDisableForSite}
@ -496,15 +277,14 @@ function FloatingButtonCloseMenu({
interface FloatingButtonLockControlProps {
expanded: boolean
side: FloatingButtonSide
}
function FloatingButtonLockControl({ expanded, side }: FloatingButtonLockControlProps) {
function FloatingButtonLockControl({ expanded }: FloatingButtonLockControlProps) {
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
const locked = floatingButton.locked
const controlOffsetClassName = !locked && !expanded
? floatingButtonControlOffsetClassNames[side].collapsed
: floatingButtonControlOffsetClassNames[side].expanded
? floatingButtonControlOffsetClassNames.collapsed
: floatingButtonControlOffsetClassNames.expanded
const handleToggleLocked = () => {
void setFloatingButton({ ...floatingButton, locked: !locked })

View file

@ -1,4 +1,3 @@
import type { FloatingButtonSide } from "@/types/config/floating-button"
import { RiTranslate } from "@remixicon/react"
import { IconCheck } from "@tabler/icons-react"
import { useAtomValue } from "jotai"
@ -7,15 +6,7 @@ import { cn } from "@/utils/styles/utils"
import { enablePageTranslationAtom } from "../../atoms"
import HiddenButton from "./components/hidden-button"
export default function TranslateButton({
className,
side = "right",
expanded = false,
}: {
className?: string
side?: FloatingButtonSide
expanded?: boolean
}) {
export default function TranslateButton({ className, expanded = false }: { className: string, expanded?: boolean }) {
const translationState = useAtomValue(enablePageTranslationAtom)
const isEnabled = translationState.enabled
@ -23,7 +14,6 @@ export default function TranslateButton({
<HiddenButton
icon={<RiTranslate className="h-5 w-5" />}
className={className}
side={side}
expanded={expanded}
onClick={() => {
void sendMessage("tryToSetEnablePageTranslationOnContentScript", { enabled: !isEnabled })

View file

@ -0,0 +1,116 @@
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

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Side Panel | Read Frog</title>
<meta name="manifest.open_at_install" content="false" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View file

@ -1,66 +0,0 @@
import "@/utils/zod-config"
import type { ThemeMode } from "@/types/config/theme"
import { Provider as JotaiProvider } from "jotai"
import { useHydrateAtoms } from "jotai/utils"
import readFrogLogo from "@/assets/icons/read-frog.png?url&no-inline"
import { ThemeProvider } from "@/components/providers/theme-provider"
import { baseThemeModeAtom } from "@/utils/atoms/theme"
import { APP_NAME } from "@/utils/constants/app"
import { renderPersistentReactRoot } from "@/utils/react-root"
import { getLocalThemeMode } from "@/utils/theme"
import "@/assets/styles/text-small.css"
import "@/assets/styles/theme.css"
function HydrateAtoms({
initialValues,
children,
}: {
initialValues: [
[typeof baseThemeModeAtom, ThemeMode],
]
children: React.ReactNode
}) {
useHydrateAtoms(initialValues)
return children
}
function SidePanelShell() {
return (
<main className="bg-background text-foreground flex min-h-screen flex-col px-5 py-6">
<section className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
<img
src={readFrogLogo}
alt={APP_NAME}
className="size-16 rounded-full"
/>
<div className="space-y-2">
<h1 className="text-xl font-semibold tracking-tight">
{APP_NAME}
</h1>
<p className="text-muted-foreground text-sm">
Side Panel is coming soon.
</p>
</div>
</section>
</main>
)
}
async function initApp() {
const root = document.getElementById("root")!
root.className = "min-h-screen bg-background text-base antialiased"
const themeMode = await getLocalThemeMode()
renderPersistentReactRoot(root, (
<JotaiProvider>
<HydrateAtoms initialValues={[[baseThemeModeAtom, themeMode]]}>
<ThemeProvider>
<SidePanelShell />
</ThemeProvider>
</HydrateAtoms>
</JotaiProvider>
))
}
void initApp()

View file

@ -9,8 +9,7 @@ declare global {
}
export default defineContentScript({
matches: ["*://*.youtube.com/*", "*://*.youtube-nocookie.com/*"],
allFrames: true,
matches: ["*://*.youtube.com/*"],
cssInjectionMode: "manifest",
async main(ctx) {
if (window.__READ_FROG_SUBTITLES_INJECTED__)

View file

@ -1,33 +1,22 @@
import { YOUTUBE_EMBED_PATH_PATTERN, YOUTUBE_NAVIGATE_FINISH_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles"
import { YOUTUBE_NAVIGATE_FINISH_EVENT, YOUTUBE_WATCH_URL_PATTERN } from "@/utils/constants/subtitles"
import { setupYoutubeSubtitles } from "./platforms/youtube"
import { getYoutubeConfig } from "./platforms/youtube/config"
import { youtubeConfig } from "./platforms/youtube/config"
import { mountSubtitlesUI } from "./renderer/mount-subtitles-ui"
function isYoutubeWatch(): boolean {
return window.location.href.includes(YOUTUBE_WATCH_URL_PATTERN)
}
function isYoutubeEmbed(): boolean {
return YOUTUBE_EMBED_PATH_PATTERN.test(window.location.pathname)
}
export function initYoutubeSubtitles() {
let initialized = false
let mountedAdapter: ReturnType<typeof setupYoutubeSubtitles> | null = null
const embedded = isYoutubeEmbed()
const config = getYoutubeConfig({ embedded })
const tryInit = async () => {
if (!isYoutubeWatch() && !embedded) {
if (!window.location.href.includes(YOUTUBE_WATCH_URL_PATTERN)) {
return
}
if (!mountedAdapter) {
mountedAdapter = setupYoutubeSubtitles(config)
mountedAdapter = setupYoutubeSubtitles()
}
await mountSubtitlesUI({ adapter: mountedAdapter, config })
await mountSubtitlesUI({ adapter: mountedAdapter, config: youtubeConfig })
if (initialized) {
return
@ -39,7 +28,5 @@ export function initYoutubeSubtitles() {
void tryInit()
if (!embedded) {
window.addEventListener(YOUTUBE_NAVIGATE_FINISH_EVENT, () => void tryInit())
}
window.addEventListener(YOUTUBE_NAVIGATE_FINISH_EVENT, () => void tryInit())
}

View file

@ -1,12 +1,9 @@
export interface ControlsConfig {
findVideoContainer?: () => HTMLElement | null
measureHeight: (container: HTMLElement) => number
checkVisibility: (container: HTMLElement) => boolean
}
export interface PlatformConfig {
embedded?: boolean
selectors: {
video: string
playerContainer: string
@ -14,10 +11,10 @@ export interface PlatformConfig {
nativeSubtitles: string
}
events: {
navigateStart?: string
navigateFinish?: string
}
events: {
navigateStart?: string
navigateFinish?: string
}
controls?: ControlsConfig

View file

@ -6,57 +6,32 @@ import {
YOUTUBE_NAVIGATE_START_EVENT,
} from "@/utils/constants/subtitles"
import { getYoutubeVideoId } from "@/utils/subtitles/video-id"
export const youtubeConfig: PlatformConfig = {
selectors: {
video: "video.html5-main-video",
playerContainer: "#movie_player.html5-video-player",
controlsBar: "#movie_player .ytp-right-controls",
nativeSubtitles: YOUTUBE_NATIVE_SUBTITLES_CLASS,
},
interface YoutubeConfigOptions {
embedded?: boolean
}
export function getYoutubeConfig(options: YoutubeConfigOptions = {}): PlatformConfig {
const { embedded } = options
return {
embedded,
selectors: {
video: "video.html5-main-video",
playerContainer: "#movie_player.html5-video-player",
controlsBar: embedded ? ".quick-actions-wrapper" : "#movie_player .ytp-right-controls",
nativeSubtitles: YOUTUBE_NATIVE_SUBTITLES_CLASS,
},
events: embedded
? {}
: {
navigateStart: YOUTUBE_NAVIGATE_START_EVENT,
navigateFinish: YOUTUBE_NAVIGATE_FINISH_EVENT,
},
controls: embedded
? {
findVideoContainer: () => document.querySelector<HTMLElement>("#movie_player"),
measureHeight: () => {
const wrapper = document.querySelector(".quick-actions-wrapper")
const player = document.querySelector("#movie_player")
const progressBar = player?.querySelector(".ytp-progress-bar-container")
if (!wrapper || !progressBar)
return DEFAULT_CONTROLS_HEIGHT
return wrapper.getBoundingClientRect().top - progressBar.getBoundingClientRect().top
},
checkVisibility: () => true,
}
: {
measureHeight: (container) => {
const player = container.closest(".html5-video-player")
const progressBar = player?.querySelector(".ytp-progress-bar-container")
const controlsBar = progressBar?.parentElement
return controlsBar?.getBoundingClientRect().height ?? DEFAULT_CONTROLS_HEIGHT
},
checkVisibility: (container) => {
const player = container.closest(".html5-video-player")
return !!player && !player.classList.contains("ytp-autohide")
},
},
getVideoId: getYoutubeVideoId,
}
}
events: {
navigateStart: YOUTUBE_NAVIGATE_START_EVENT,
navigateFinish: YOUTUBE_NAVIGATE_FINISH_EVENT,
},
controls: {
measureHeight: (container) => {
const player = container.closest(".html5-video-player")
const progressBar = player?.querySelector(".ytp-progress-bar-container")
const controlsBar = progressBar?.parentElement
return controlsBar?.getBoundingClientRect().height ?? DEFAULT_CONTROLS_HEIGHT
},
checkVisibility: (container) => {
const player = container.closest(".html5-video-player")
return !!player && !player.classList.contains("ytp-autohide")
},
},
getVideoId: getYoutubeVideoId,
}

View file

@ -1,12 +1,12 @@
import type { PlatformConfig } from "@/entrypoints/subtitles.content/platforms"
import { YoutubeSubtitlesFetcher } from "@/utils/subtitles/fetchers"
import { UniversalVideoAdapter } from "../../universal-adapter"
import { youtubeConfig } from "./config"
export function setupYoutubeSubtitles(config: PlatformConfig) {
export function setupYoutubeSubtitles() {
const subtitlesFetcher = new YoutubeSubtitlesFetcher()
return new UniversalVideoAdapter({
config,
config: youtubeConfig,
subtitlesFetcher,
})
}

View file

@ -1,5 +1,6 @@
import type { SubtitlesProvidersAdapter } from "../ui/subtitles-ui-context"
import type { UniversalVideoAdapter } from "../universal-adapter"
import type { PlatformConfig } from "@/entrypoints/subtitles.content/platforms"
import { Provider as JotaiProvider } from "jotai"
import ReactDOM from "react-dom/client"
import { Toaster } from "sonner"
import themeCSS from "@/assets/styles/theme.css?inline"
@ -8,13 +9,19 @@ import { REACT_SHADOW_HOST_CLASS } from "@/utils/constants/dom-labels"
import { waitForElement } from "@/utils/dom/wait-for-element"
import { ShadowWrapperContext } from "@/utils/react-shadow-host/create-shadow-host"
import { ShadowHostBuilder } from "@/utils/react-shadow-host/shadow-host-builder"
import { subtitlesStore } from "../atoms"
import { SubtitlesContainer } from "../ui/subtitles-container"
import { SubtitlesProviders } from "../ui/subtitles-ui-context"
import { SubtitlesUIContext } from "../ui/subtitles-ui-context"
const SUBTITLES_UI_HOST_ID = "read-frog-subtitles-ui-host"
type MountSubtitlesUIAdapter = Pick<
UniversalVideoAdapter,
"downloadSourceSubtitles" | "getControlsConfig" | "toggleSubtitlesManually"
>
interface MountSubtitlesUIOptions {
adapter: SubtitlesProvidersAdapter
adapter: MountSubtitlesUIAdapter
config: Pick<PlatformConfig, "selectors">
}
@ -83,14 +90,22 @@ export async function mountSubtitlesUI(
parentEl.appendChild(shadowHost)
const app = (
<ShadowWrapperContext value={reactContainer}>
<ThemeProvider container={reactContainer}>
<SubtitlesProviders adapter={adapter}>
<SubtitlesContainer />
<Toaster richColors className="z-2147483647 notranslate" />
</SubtitlesProviders>
</ThemeProvider>
</ShadowWrapperContext>
<JotaiProvider store={subtitlesStore}>
<ShadowWrapperContext value={reactContainer}>
<ThemeProvider container={reactContainer}>
<SubtitlesUIContext
value={{
toggleSubtitles: adapter.toggleSubtitlesManually,
downloadSourceSubtitles: adapter.downloadSourceSubtitles,
controlsConfig: adapter.getControlsConfig(),
}}
>
<SubtitlesContainer />
<Toaster richColors className="z-2147483647 notranslate" />
</SubtitlesUIContext>
</ThemeProvider>
</ShadowWrapperContext>
</JotaiProvider>
)
reactRoot.render(app)

View file

@ -0,0 +1,44 @@
import * as React from "react"
import themeCSS from "@/assets/styles/theme.css?inline"
import { TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles"
import { createReactShadowHost } from "@/utils/react-shadow-host/create-shadow-host"
import { SubtitlesTranslateButton } from "../ui/subtitles-translate-button"
const wrapperCSS = `
:host {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
height: 100%;
margin: 0;
padding: 0;
}
.light, .dark {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
`
export function renderSubtitlesTranslateButton(): HTMLDivElement {
const existingContainer = document.querySelector<HTMLDivElement>(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
if (existingContainer) {
return existingContainer
}
const component = React.createElement(SubtitlesTranslateButton)
const shadowHost = createReactShadowHost(component, {
position: "inline",
inheritStyles: false,
cssContent: [themeCSS, wrapperCSS],
}) as HTMLDivElement
shadowHost.id = TRANSLATE_BUTTON_CONTAINER_ID
return shadowHost
}

View file

@ -1,73 +0,0 @@
import type { SubtitlesProvidersAdapter } from "../ui/subtitles-ui-context"
import themeCSS from "@/assets/styles/theme.css?inline"
import { TRANSLATE_BUTTON_CONTAINER_ID } from "@/utils/constants/subtitles"
import { createReactShadowHost } from "@/utils/react-shadow-host/create-shadow-host"
import { SubtitlesSettingsPanel } from "../ui/subtitles-settings-panel"
import { SubtitlesTranslateButton } from "../ui/subtitles-translate-button"
import { SubtitlesProviders } from "../ui/subtitles-ui-context"
const wrapperCSS = `
:host {
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
height: 100%;
margin: 0;
padding: 0;
}
.light, .dark {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
`
const embedWrapperCSS = `
:host {
display: inline-flex;
align-items: center;
position: relative;
height: 100%;
}
.light, .dark {
display: flex;
align-items: center;
height: 100%;
position: relative;
}
`
export function renderSubtitlesTranslateButton(adapter: SubtitlesProvidersAdapter): HTMLDivElement {
const existingContainer = document.querySelector<HTMLDivElement>(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
if (existingContainer)
return existingContainer
const component = adapter.embedded
? (
<SubtitlesProviders adapter={adapter}>
<SubtitlesTranslateButton />
<SubtitlesSettingsPanel />
</SubtitlesProviders>
)
: <SubtitlesTranslateButton />
const shadowHost = createReactShadowHost(component, {
position: "inline",
inheritStyles: false,
cssContent: [themeCSS, adapter.embedded ? embedWrapperCSS : wrapperCSS],
...(adapter.embedded && { style: { position: "relative" }, forcedTheme: "dark" as const }),
}) as HTMLDivElement
shadowHost.id = TRANSLATE_BUTTON_CONTAINER_ID
if (adapter.embedded) {
for (const eventType of ["click", "mousedown", "pointerdown", "dblclick"]) {
shadowHost.addEventListener(eventType, e => e.stopPropagation())
}
}
return shadowHost
}

View file

@ -1,16 +1,13 @@
import { useAtomValue } from "jotai"
import { use } from "react"
import { subtitlesDisplayAtom, subtitlesShowContentAtom, subtitlesShowStateAtom } from "../atoms"
import { StateMessage } from "./state-message"
import { SubtitlesSettingsPanel } from "./subtitles-settings-panel"
import { SubtitlesUIContext } from "./subtitles-ui-context"
import { SubtitlesView } from "./subtitles-view"
export function SubtitlesContainer() {
const { stateData, isVisible } = useAtomValue(subtitlesDisplayAtom)
const showState = useAtomValue(subtitlesShowStateAtom)
const showContent = useAtomValue(subtitlesShowContentAtom)
const ui = use(SubtitlesUIContext)
return (
<div className="absolute inset-0 pointer-events-none overflow-visible">
@ -23,11 +20,9 @@ export function SubtitlesContainer() {
)}
</div>
{!ui?.embedded && (
<div className="absolute inset-0 z-40 overflow-visible">
<SubtitlesSettingsPanel />
</div>
)}
<div className="absolute inset-0 z-40 overflow-visible">
<SubtitlesSettingsPanel />
</div>
</div>
)
}

View file

@ -1,3 +1,4 @@
import type { RefObject } from "react"
import { IconChevronLeft } from "@tabler/icons-react"
import { Activity, useMemo, useRef } from "react"
import { Button } from "@/components/ui/base-ui/button"
@ -39,58 +40,6 @@ function TransitionContent({
)
}
function PanelContent({
children,
panelRef,
header,
transition,
maxHeight,
}: {
children: React.ReactNode
panelRef: React.RefObject<HTMLDivElement | null>
header?: PanelShellProps["header"]
transition?: PanelShellProps["transition"]
maxHeight?: string
}) {
return (
<div
ref={panelRef}
data-slot="subtitles-settings-panel"
className="bg-popover text-popover-foreground border-border pointer-events-auto relative isolate z-40 flex w-[min(19rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-[20px] border shadow-floating backdrop-blur-2xl"
style={{ maxHeight }}
>
<Activity mode={header ? "visible" : "hidden"}>
<div className="border-border flex items-center gap-3 border-b px-4 pt-3 pb-3">
<Button
type="button"
variant="ghost-secondary"
size="icon-sm"
aria-label="Back to subtitles menu"
onClick={header?.onBack}
className="rounded-full"
>
<IconChevronLeft className="size-4" />
</Button>
<div className="min-w-0 truncate text-xs font-medium">
{header?.title}
</div>
</div>
</Activity>
<div className="min-h-0 flex-1 overflow-y-auto">
{transition
? (
<TransitionContent direction={transition.direction} transitionKey={transition.key}>
{children}
</TransitionContent>
)
: children}
</div>
</div>
)
}
export function PanelShell({
children,
open,
@ -100,8 +49,8 @@ export function PanelShell({
}: PanelShellProps) {
const rootRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const { controlsConfig, embedded } = useSubtitlesUI()
const { controlsHeight, controlsVisible } = useControlsInfo(rootRef, controlsConfig)
const { controlsConfig } = useSubtitlesUI()
const { controlsHeight, controlsVisible } = useControlsInfo(rootRef as RefObject<HTMLElement>, controlsConfig)
const bottomOffset = useMemo(
() => (controlsVisible ? controlsHeight + 18 : 22),
@ -114,36 +63,54 @@ export function PanelShell({
panelRef,
})
const rootClassName = embedded
? "relative z-40 pointer-events-none font-light h-full"
: "absolute inset-0 z-40 pointer-events-none overflow-visible font-light [container-type:size]"
const positionClassName = cn(
"absolute z-40 transition-[bottom,opacity,transform] duration-200 ease-out",
embedded ? "bottom-full right-0" : "right-4",
open ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0",
)
const positionStyle = embedded
? { marginBottom: `${bottomOffset}px` }
: { bottom: `${bottomOffset}px` }
const maxHeight = embedded
? "min(24rem, 60vh)"
: `calc(100cqh - ${bottomOffset}px - 1rem)`
return (
<div ref={rootRef} className={rootClassName}>
<div
ref={rootRef}
className="absolute inset-0 z-40 pointer-events-none overflow-visible font-light [container-type:size]"
>
<Activity mode={open ? "visible" : "hidden"}>
<div className={positionClassName} style={positionStyle}>
<PanelContent
panelRef={panelRef}
header={header}
transition={transition}
maxHeight={maxHeight}
<div
className={cn(
"absolute right-4 z-40 transition-[bottom,opacity,transform] duration-200 ease-out",
open ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0",
)}
style={{ bottom: `${bottomOffset}px` }}
>
<div
ref={panelRef}
data-slot="subtitles-settings-panel"
className="bg-popover text-popover-foreground border-border pointer-events-auto relative isolate z-40 flex w-[min(19rem,calc(100vw-2rem))] flex-col overflow-hidden rounded-[20px] border shadow-floating backdrop-blur-2xl"
style={{ maxHeight: `calc(100cqh - ${bottomOffset}px - 1rem)` }}
>
{children}
</PanelContent>
{header && (
<div className="border-border flex items-center gap-3 border-b px-4 pt-3 pb-3">
<Button
type="button"
variant="ghost-secondary"
size="icon-sm"
aria-label="Back to subtitles menu"
onClick={header.onBack}
className="rounded-full"
>
<IconChevronLeft className="size-4" />
</Button>
<div className="min-w-0 truncate text-xs font-medium">
{header.title}
</div>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{transition
? (
<TransitionContent direction={transition.direction} transitionKey={transition.key}>
{children}
</TransitionContent>
)
: children}
</div>
</div>
</div>
</Activity>
</div>

View file

@ -1,14 +1,10 @@
import type { ControlsConfig } from "@/entrypoints/subtitles.content/platforms"
import type { UniversalVideoAdapter } from "@/entrypoints/subtitles.content/universal-adapter"
import { Provider as JotaiProvider } from "jotai"
import { createContext, use } from "react"
import { subtitlesStore } from "../atoms"
interface SubtitlesUIContextValue {
toggleSubtitles: (enabled: boolean) => void
downloadSourceSubtitles: () => Promise<void>
controlsConfig?: ControlsConfig
embedded?: boolean
}
export const SubtitlesUIContext = createContext<SubtitlesUIContextValue | null>(null)
@ -20,31 +16,3 @@ export function useSubtitlesUI() {
}
return ui
}
export type SubtitlesProvidersAdapter = Pick<
UniversalVideoAdapter,
"downloadSourceSubtitles" | "embedded" | "getControlsConfig" | "toggleSubtitlesManually"
>
export function SubtitlesProviders({
adapter,
children,
}: {
adapter: SubtitlesProvidersAdapter
children: React.ReactNode
}) {
return (
<JotaiProvider store={subtitlesStore}>
<SubtitlesUIContext
value={{
toggleSubtitles: adapter.toggleSubtitlesManually,
downloadSourceSubtitles: adapter.downloadSourceSubtitles,
controlsConfig: adapter.getControlsConfig(),
embedded: adapter.embedded,
}}
>
{children}
</SubtitlesUIContext>
</JotaiProvider>
)
}

View file

@ -1,17 +1,17 @@
import { IconGripHorizontal } from "@tabler/icons-react"
import { useAtomValue, useSetAtom } from "jotai"
import { Activity, useRef } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { SUBTITLES_VIEW_CLASS } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import { MainSubtitle, TranslationSubtitle } from "./subtitle-lines"
import { useSubtitlesUI } from "./subtitles-ui-context"
import { useControlsInfo } from "./use-controls-visible"
import { useVerticalDrag } from "./use-vertical-drag"
interface SubtitlesViewProps {
showContent: boolean
}
import { IconGripHorizontal } from "@tabler/icons-react"
import { useAtomValue, useSetAtom } from "jotai"
import { Activity, useRef } from "react"
import { configFieldsAtomMap } from "@/utils/atoms/config"
import { SUBTITLES_VIEW_CLASS } from "@/utils/constants/subtitles"
import { cn } from "@/utils/styles/utils"
import { MainSubtitle, TranslationSubtitle } from "./subtitle-lines"
import { useSubtitlesUI } from "./subtitles-ui-context"
import { useControlsInfo } from "./use-controls-visible"
import { useVerticalDrag } from "./use-vertical-drag"
interface SubtitlesViewProps {
showContent: boolean
}
function SubtitlesContent() {
const { style } = useAtomValue(configFieldsAtomMap.videoSubtitles)
@ -43,11 +43,11 @@ function SubtitlesContent() {
)
}
export function SubtitlesView({ showContent }: SubtitlesViewProps) {
const windowRef = useRef<HTMLDivElement>(null)
const { controlsConfig } = useSubtitlesUI()
const { controlsVisible, controlsHeight } = useControlsInfo(windowRef, controlsConfig)
const setVideoSubtitles = useSetAtom(configFieldsAtomMap.videoSubtitles)
export function SubtitlesView({ showContent }: SubtitlesViewProps) {
const windowRef = useRef<HTMLDivElement>(null)
const { controlsConfig } = useSubtitlesUI()
const { controlsVisible, controlsHeight } = useControlsInfo(windowRef, controlsConfig)
const setVideoSubtitles = useSetAtom(configFieldsAtomMap.videoSubtitles)
const { refs, windowStyle, positionStyle, isDragging } = useVerticalDrag({
controlsVisible,

View file

@ -28,9 +28,12 @@ export function useControlsInfo(
return
const element = elementRef.current
const shadowRoot = element ? getContainingShadowRoot(element) : null
if (!element)
return
const shadowRoot = getContainingShadowRoot(element)
const shadowHost = shadowRoot?.host as HTMLElement | undefined
const videoContainer = shadowHost?.parentElement ?? controlsConfig.findVideoContainer?.()
const videoContainer = shadowHost?.parentElement
if (!videoContainer)
return

View file

@ -44,10 +44,6 @@ export class UniversalVideoAdapter {
private translationCoordinator: TranslationCoordinator | null = null
private subtitlesSummaryContextHash: string | null = null
get embedded() {
return this.config.embedded
}
get videoIdChanged() {
const currentVideoId = this.config.getVideoId?.()
return !!(this.sessionVideoId && currentVideoId && currentVideoId !== this.sessionVideoId)
@ -249,49 +245,25 @@ export class UniversalVideoAdapter {
}
private async renderTranslateButton() {
const container = await waitForElement(this.config.selectors.controlsBar)
if (!container) {
if (!this.config.embedded)
toast.error(i18n.t("subtitles.errors.controlsBarNotFound"))
const controlsBar = await waitForElement(this.config.selectors.controlsBar)
if (!controlsBar) {
toast.error(i18n.t("subtitles.errors.controlsBarNotFound"))
return
}
const existingButton = container.querySelector(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
const existingButton = controlsBar.querySelector(`#${TRANSLATE_BUTTON_CONTAINER_ID}`)
existingButton?.remove()
const toggleButton = renderSubtitlesTranslateButton(this)
const toggleButton = renderSubtitlesTranslateButton()
if (this.config.embedded) {
container.appendChild(toggleButton)
}
else {
container.insertBefore(toggleButton, container.firstChild)
}
controlsBar.insertBefore(toggleButton, controlsBar.firstChild)
}
private async tryAutoStartSubtitles() {
const config = await getLocalConfig()
const autoStart = config?.videoSubtitles?.autoStart ?? false
if (!autoStart)
return
if (this.config.embedded) {
const video = this.subtitlesScheduler?.getVideoElement()
if (!video)
return
const start = () => {
video.removeEventListener("playing", start)
this.toggleSubtitlesWithSource(true, "auto")
}
if (!video.paused) {
this.toggleSubtitlesWithSource(true, "auto")
}
else {
video.addEventListener("playing", start)
}
if (!autoStart) {
return
}

View file

@ -9,10 +9,6 @@ contextMenu:
translate: Translate
translateSelection: Translate "%s"
showOriginal: Show Original
sidePanel:
firefoxUserActionHint: In Firefox, open Read Frog from the browser sidebar.
firefoxUserActionHelpText: Learn how to open the Firefox sidebar
firefoxUserActionHelpUrl: https://support.mozilla.org/en-US/kb/use-sidebar-access-tools-and-vertical-tabs
popup:
theme: Theme
autoLang: Auto
@ -289,7 +285,7 @@ options:
clickAction:
title: Click Action
description: Choose the action when clicking the floating button
panel: Open Side Panel
panel: Open Panel
translate: Toggle Page Translation
selectionToolbar:
globalToggle:

View file

@ -9,10 +9,6 @@ contextMenu:
translate: 翻訳
translateSelection: 「%s」を翻訳
showOriginal: 原文を表示
sidePanel:
firefoxUserActionHint: Firefox では、ブラウザーのサイドバーから Read Frog を開いてください。
firefoxUserActionHelpText: Firefox サイドバーの開き方を見る
firefoxUserActionHelpUrl: https://support.mozilla.org/ja/kb/use-sidebar-access-tools-and-vertical-tabs
popup:
theme: テーマ
autoLang: 自動検出
@ -288,7 +284,7 @@ options:
clickAction:
title: クリック動作
description: フローティングボタンをクリックしたときの動作を選択
panel: サイドパネルを開く
panel: パネルを開く
translate: ページ翻訳を切り替え
selectionToolbar:
globalToggle:

View file

@ -9,10 +9,6 @@ contextMenu:
translate: 번역
translateSelection: '"%s" 번역'
showOriginal: 원문 보기
sidePanel:
firefoxUserActionHint: Firefox에서는 브라우저 사이드바에서 Read Frog를 열어 주세요.
firefoxUserActionHelpText: Firefox 사이드바 여는 방법 보기
firefoxUserActionHelpUrl: https://support.mozilla.org/ko/kb/use-sidebar-access-tools-and-vertical-tabs
popup:
theme: 테마
autoLang: 자동
@ -288,7 +284,7 @@ options:
clickAction:
title: 클릭 동작
description: 플로팅 버튼을 클릭할 때 실행할 동작 선택
panel: 사이드 패널 열기
panel: 패널 열기
translate: 페이지 번역 전환
selectionToolbar:
globalToggle:

View file

@ -9,10 +9,6 @@ contextMenu:
translate: Перевести
translateSelection: Перевести "%s"
showOriginal: Показать оригинал
sidePanel:
firefoxUserActionHint: В Firefox откройте Read Frog из боковой панели браузера.
firefoxUserActionHelpText: Как открыть боковую панель Firefox
firefoxUserActionHelpUrl: https://support.mozilla.org/ru/kb/ispolzovanije-bokovoi-paneli-dostup-instrumenty-i-vertikalnyje-vkladki
popup:
theme: Тема
autoLang: Авто
@ -288,7 +284,7 @@ options:
clickAction:
title: Действие по клику
description: Выберите действие при нажатии на плавающую кнопку
panel: Открыть боковую панель
panel: Открыть панель
translate: Переключить перевод страницы
selectionToolbar:
globalToggle:

View file

@ -9,10 +9,6 @@ contextMenu:
translate: Çevir
translateSelection: '"%s" Çevir'
showOriginal: Orijinali Göster
sidePanel:
firefoxUserActionHint: Firefox'ta Read Frog'u tarayıcının kenar çubuğundan açın.
firefoxUserActionHelpText: Firefox kenar çubuğunu açmayı öğrenin
firefoxUserActionHelpUrl: https://support.mozilla.org/tr/kb/use-sidebar-access-tools-and-vertical-tabs
popup:
theme: Tema
autoLang: Otomatik
@ -288,7 +284,7 @@ options:
clickAction:
title: Tıklama Eylemi
description: Yüzen butona tıklandığında gerçekleştirilecek eylemi seçin
panel: Yan Paneli Aç
panel: Paneli Aç
translate: Sayfa Çevirisini Aç/Kapat
selectionToolbar:
globalToggle:

View file

@ -9,10 +9,6 @@ contextMenu:
translate: Dịch
translateSelection: Dịch "%s"
showOriginal: Hiển thị bản gốc
sidePanel:
firefoxUserActionHint: Trong Firefox, hãy mở Read Frog từ thanh bên của trình duyệt.
firefoxUserActionHelpText: Xem cách mở thanh bên Firefox
firefoxUserActionHelpUrl: https://support.mozilla.org/vi/kb/use-sidebar-access-tools-and-vertical-tabs
popup:
theme: Chủ đề
autoLang: Tự động
@ -288,7 +284,7 @@ options:
clickAction:
title: Hành động khi nhấn
description: Chọn hành động khi nhấn vào nút nổi
panel: Mở bảng điều khiển bên
panel: Mở bảng điều khiển
translate: Bật/tắt dịch trang
selectionToolbar:
globalToggle:

View file

@ -9,10 +9,6 @@ contextMenu:
translate: 翻译
translateSelection: 翻译“%s”
showOriginal: 显示原文
sidePanel:
firefoxUserActionHint: 在 Firefox 中,请从浏览器侧边栏打开陪读蛙。
firefoxUserActionHelpText: 查看 Firefox 侧边栏说明
firefoxUserActionHelpUrl: https://support.mozilla.org/zh-CN/kb/use-sidebar-access-tools-and-vertical-tabs
popup:
theme: 主题
autoLang: 自动检测
@ -289,7 +285,7 @@ options:
clickAction:
title: 点击行为
description: 选择点击悬浮按钮时执行的操作
panel: 打开侧边栏
panel: 打开面板
translate: 切换页面翻译
selectionToolbar:
globalToggle:

View file

@ -9,10 +9,6 @@ contextMenu:
translate: 翻譯
translateSelection: 翻譯「%s」
showOriginal: 顯示原文
sidePanel:
firefoxUserActionHint: 在 Firefox 中,請從瀏覽器側邊欄開啟陪讀蛙。
firefoxUserActionHelpText: 查看 Firefox 側邊欄說明
firefoxUserActionHelpUrl: https://support.mozilla.org/zh-TW/kb/use-sidebar-access-tools-and-vertical-tabs
popup:
theme: 主題
autoLang: 自動偵測
@ -288,7 +284,7 @@ options:
clickAction:
title: 點擊行為
description: 選擇點擊懸浮按鈕時執行的操作
panel: 開啟側邊欄
panel: 開啟面板
translate: 切換頁面翻譯
selectionToolbar:
globalToggle:

View file

@ -1,29 +0,0 @@
import { describe, expect, it } from "vitest"
import { DEFAULT_CONFIG } from "@/utils/constants/config"
import {
floatingButtonClickActionSchema,
floatingButtonSchema,
floatingButtonSideSchema,
} from "../floating-button"
describe("floating button config validation", () => {
it.each(["left", "right"])("allows the %s side", (side) => {
expect(floatingButtonSideSchema.safeParse(side).success).toBe(true)
})
it("rejects unknown sides", () => {
expect(floatingButtonSideSchema.safeParse("center").success).toBe(false)
})
it.each(["panel", "translate"])("allows the %s click action", (clickAction) => {
expect(floatingButtonClickActionSchema.safeParse(clickAction).success).toBe(true)
})
it("rejects unknown click actions", () => {
expect(floatingButtonClickActionSchema.safeParse("open").success).toBe(false)
})
it("accepts the default floating button config", () => {
expect(floatingButtonSchema.safeParse(DEFAULT_CONFIG.floatingButton).success).toBe(true)
})
})

View file

@ -7,7 +7,6 @@ import {
MIN_SELECTION_OVERLAY_OPACITY,
} from "@/utils/constants/selection"
import { MIN_SIDE_CONTENT_WIDTH } from "@/utils/constants/side"
import { floatingButtonSchema } from "./floating-button"
import { languageDetectionConfigSchema } from "./language-detection"
import { isLLMProvider, NON_API_TRANSLATE_PROVIDERS_MAP, providersConfigSchema } from "./provider"
import { selectionToolbarCustomActionsSchema } from "./selection-toolbar"
@ -21,6 +20,15 @@ const languageSchema = z.object({
level: langLevel,
})
// Floating button schema
const floatingButtonSchema = z.object({
enabled: z.boolean(),
position: z.number().min(0).max(1),
disabledFloatingButtonPatterns: z.array(z.string()),
clickAction: z.enum(["panel", "translate"]),
locked: z.boolean(),
})
const selectionToolbarFeatureSchema = z.object({
enabled: z.boolean(),
providerId: z.string().nonempty(),

View file

@ -1,20 +0,0 @@
import z from "zod"
export const floatingButtonSides = ["left", "right"] as const
export type FloatingButtonSide = (typeof floatingButtonSides)[number]
export const floatingButtonSideSchema = z.enum(floatingButtonSides)
export const floatingButtonClickActions = ["panel", "translate"] as const
export type FloatingButtonClickAction = (typeof floatingButtonClickActions)[number]
export const floatingButtonClickActionSchema = z.enum(floatingButtonClickActions)
export const floatingButtonSchema = z.object({
enabled: z.boolean(),
position: z.number().min(0).max(1),
side: floatingButtonSideSchema,
disabledFloatingButtonPatterns: z.array(z.string()),
clickAction: floatingButtonClickActionSchema,
locked: z.boolean(),
})
export type FloatingButtonConfig = z.infer<typeof floatingButtonSchema>

View file

@ -1,18 +0,0 @@
import type { VersionTestData } from "./types"
import { testSeries as v068TestSeries } from "./v068"
export const testSeries = Object.fromEntries(
Object.entries(v068TestSeries).map(([seriesId, seriesData]) => [
seriesId,
{
...seriesData,
config: {
...seriesData.config,
floatingButton: {
...seriesData.config.floatingButton,
side: "right",
},
},
},
]),
) as VersionTestData["testSeries"]

View file

@ -1,33 +0,0 @@
import { describe, expect, it } from "vitest"
import { migrate } from "../../migration-scripts/v068-to-v069"
describe("v068-to-v069 migration", () => {
it("adds floatingButton.side with a default right value", () => {
const migrated = migrate({
floatingButton: {
enabled: true,
position: 0.66,
disabledFloatingButtonPatterns: [],
clickAction: "translate",
locked: false,
},
})
expect(migrated.floatingButton.side).toBe("right")
})
it("preserves an existing floatingButton.side value", () => {
const migrated = migrate({
floatingButton: {
enabled: true,
position: 0.66,
side: "left",
disabledFloatingButtonPatterns: [],
clickAction: "translate",
locked: false,
},
})
expect(migrated.floatingButton.side).toBe("left")
})
})

View file

@ -1,17 +0,0 @@
/**
* Migration script from v068 to v069
* - Adds `floatingButton.side` with a default value of `"right"`
*
* IMPORTANT: All values are hardcoded inline. Migration scripts are frozen
* snapshots never import constants or helpers that may change.
*/
export function migrate(oldConfig: any): any {
return {
...oldConfig,
floatingButton: {
...oldConfig?.floatingButton,
side: oldConfig?.floatingButton?.side ?? "right",
},
}
}

View file

@ -118,12 +118,6 @@ describe("getProviderOptions", () => {
const deepseekReasonerOptions = getProviderOptions("deepseek-reasoner", "deepseek")
expect(deepseekReasonerOptions.deepseek?.thinking).toEqual({ type: "disabled" })
const deepseekV4FlashOptions = getProviderOptions("deepseek-v4-flash", "deepseek")
expect(deepseekV4FlashOptions.deepseek?.thinking).toEqual({ type: "disabled" })
const deepseekV4ProOptions = getProviderOptions("deepseek-v4-pro", "deepseek")
expect(deepseekV4ProOptions.deepseek?.thinking).toEqual({ type: "disabled" })
const cohereReasoningOptions = getProviderOptions("command-a-reasoning-08-2025", "cohere")
expect(cohereReasoningOptions.cohere?.thinking).toEqual({ type: "disabled" })

View file

@ -1,5 +1,4 @@
import type { Config } from "@/types/config/config"
import type { FloatingButtonSide } from "@/types/config/floating-button"
import type { SelectionToolbarCustomAction } from "@/types/config/selection-toolbar"
import type { PageTranslateRange } from "@/types/config/translate"
import { CUSTOM_ACTION_TEMPLATES } from "./custom-action-templates"
@ -19,10 +18,9 @@ export const GOOGLE_DRIVE_TOKEN_STORAGE_KEY = "__googleDriveToken"
export const THEME_STORAGE_KEY = "theme"
export const DETECTED_CODE_STORAGE_KEY = "detectedCode"
export const DEFAULT_DETECTED_CODE = "eng" as const
export const CONFIG_SCHEMA_VERSION = 69
export const CONFIG_SCHEMA_VERSION = 68
export const DEFAULT_FLOATING_BUTTON_POSITION = 0.66
export const DEFAULT_FLOATING_BUTTON_SIDE: FloatingButtonSide = "right"
function createDefaultDictionaryAction(): SelectionToolbarCustomAction | null {
const template = CUSTOM_ACTION_TEMPLATES.find(t => t.id === "dictionary")
@ -95,7 +93,6 @@ export const DEFAULT_CONFIG: Config = {
floatingButton: {
enabled: true,
position: DEFAULT_FLOATING_BUTTON_POSITION,
side: DEFAULT_FLOATING_BUTTON_SIDE,
disabledFloatingButtonPatterns: [],
clickAction: "translate",
locked: false,

View file

@ -20,7 +20,7 @@ interface OpenAIGPT5ReasoningEffortPolicy {
export const LLM_PROVIDER_MODELS = {
"openai": ["gpt-5.4-pro", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.3-chat-latest", "gpt-5.2-pro", "gpt-5.2", "gpt-5.2-chat-latest", "gpt-5.1-codex-mini", "gpt-5.1-codex", "gpt-5.1", "gpt-5.1-chat-latest", "gpt-5-pro", "gpt-5-codex", "gpt-5", "gpt-5-mini", "gpt-5-nano", "gpt-5-chat-latest", "gpt-4.1-nano", "gpt-4.1-mini", "gpt-4.1", "gpt-4o-mini", "gpt-4o"],
"deepseek": ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"],
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
"google": ["gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-preview-06-17", "gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash", "gemini-1.5-flash-8b", "gemini-1.5-flash-8b-latest", "gemini-1.5-flash", "gemini-1.5-flash-latest", "gemini-1.5-pro", "gemini-1.5-pro-latest"],
"anthropic": ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5", "claude-sonnet-4-5", "claude-opus-4-5", "claude-opus-4-1", "claude-sonnet-4-0", "claude-opus-4-0", "claude-3-7-sonnet-latest", "claude-3-5-haiku-latest"],
"siliconflow": ["Qwen/Qwen3-Next-80B-A3B-Instruct"],
@ -168,9 +168,9 @@ export const LLM_MODEL_OPTIONS: Array<{
options: { reasoningEffort: "none" } satisfies GroqProviderOptions as Record<string, JSONValue>,
},
// DeepSeek reasoning models - disable thinking by default
// DeepSeek reasoning model - disable thinking by default
{
pattern: /^deepseek-(?:reasoner|v4-(?:flash|pro))$/,
pattern: /^deepseek-reasoner$/,
options: { thinking: { type: "disabled" } } satisfies DeepSeekLanguageModelOptions as Record<string, JSONValue>,
},

View file

@ -26,7 +26,6 @@ export const TRANSLATE_BUTTON_CLASS = "read-frog-subtitles-translate-button"
// YouTube specific
export const YOUTUBE_WATCH_URL_PATTERN = "youtube.com/watch"
export const YOUTUBE_EMBED_PATH_PATTERN = /\/embed\/[^/?]+/
export const YOUTUBE_NAVIGATE_START_EVENT = "yt-navigate-start"
export const YOUTUBE_NAVIGATE_FINISH_EVENT = "yt-navigate-finish"
export const YOUTUBE_NATIVE_SUBTITLES_CLASS = ".ytp-caption-window-container"

View file

@ -26,7 +26,6 @@ interface ProtocolMap {
// navigation
openPage: (data: { url: string, active?: boolean }) => void
openOptionsPage: () => void
toggleSidePanel: (data?: { source?: "content-script" | "extension-user-action" }) => Promise<{ ok: true, action: "opened" | "closed" } | { ok: false, reason: "missing-window" | "unsupported" | "toggle-failed" | "requires-extension-user-action" }>
// config
getInitialConfig: () => Config | null
// translation state

View file

@ -1,4 +1,3 @@
import type { Theme } from "@/types/config/theme"
import { createContext } from "react"
import ReactDOM from "react-dom/client"
@ -17,10 +16,9 @@ export function createReactShadowHost(
className?: string
cssContent?: string[]
style?: Partial<CSSStyleDeclaration>
forcedTheme?: Theme
},
) {
const { className, position, inheritStyles, cssContent, style, forcedTheme } = options
const { className, position, inheritStyles, cssContent, style } = options
const shadowHost = document.createElement("div")
if (className)
@ -41,7 +39,7 @@ export function createReactShadowHost(
const root = ReactDOM.createRoot(innerReactContainer)
const wrappedComponent = (
<ShadowWrapperContext value={innerReactContainer}>
<ThemeProvider container={innerReactContainer} forcedTheme={forcedTheme}>
<ThemeProvider container={innerReactContainer}>
<TooltipProvider>
{component}
</TooltipProvider>

View file

@ -41,7 +41,7 @@ export default defineConfig({
"identity",
"scripting",
"webNavigation",
...(browser !== "firefox" ? ["offscreen", "sidePanel"] : []),
...(browser !== "firefox" ? ["offscreen"] : []),
],
host_permissions: [
"*://*/*", // Required for scripting.executeScript in any frame