mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-04-30 01:56:46 +00:00
Compare commits
3 commits
main
...
feat/float
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
120dc01f4f | ||
|
|
ed5319949f | ||
|
|
7778259f89 |
69 changed files with 582 additions and 2565 deletions
5
.changeset/fix-defuddle-markdown.md
Normal file
5
.changeset/fix-defuddle-markdown.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(extension): ensure Defuddle webpage context returns Markdown
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(options): preserve focused provider options drafts
|
||||
5
.changeset/golden-styles-shine.md
Normal file
5
.changeset/golden-styles-shine.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": minor
|
||||
---
|
||||
|
||||
feat(subtitles): add subtitle style settings panel with Trancy-inspired UI
|
||||
5
.changeset/itchy-nights-push.md
Normal file
5
.changeset/itchy-nights-push.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
feat: add floating button controls
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(page-translation): re-walk revealed accordion content
|
||||
5
.changeset/smooth-frogs-dance.md
Normal file
5
.changeset/smooth-frogs-dance.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
style(extension): align primary theme tokens and translation brand colors
|
||||
|
|
@ -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"
|
||||
'''
|
||||
|
|
|
|||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
116
src/entrypoints/side.content/components/side-content/index.tsx
Normal file
116
src/entrypoints/side.content/components/side-content/index.tsx
Normal 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" />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
|
|
@ -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__)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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" })
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue