Compare commits

...

3 commits

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

View file

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

View file

@ -22,6 +22,7 @@ vi.mock("@/utils/atoms/config", () => ({
position: 0.66,
clickAction: "panel",
disabledFloatingButtonPatterns: [],
locked: false,
}),
sideContent: atom({ width: 360 }),
},
@ -38,13 +39,13 @@ vi.mock("../../../index", () => ({
}))
vi.mock("../translate-button", () => ({
default: ({ className }: { className?: string }) => (
default: ({ className }: { className?: string, expanded?: boolean }) => (
<div data-testid="translate-button" className={className} />
),
}))
vi.mock("../components/hidden-button", () => ({
default: ({ className, onClick }: { className?: string, onClick: () => void }) => (
default: ({ className, onClick }: { className?: string, onClick: () => void, expanded?: boolean }) => (
<button type="button" data-testid="hidden-button" className={className} onClick={onClick} />
),
}))
@ -63,25 +64,86 @@ beforeAll(() => {
vi.stubGlobal("ResizeObserver", ResizeObserverMock)
})
describe("floatingButton close trigger", () => {
it("keeps the close trigger in the layout with visibility classes instead of display:none", () => {
describe("floatingButton controls", () => {
it("shows the close trigger only after entering the main floating button", () => {
render(<FloatingButton />)
const closeTrigger = screen.getByTitle("Close floating button")
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const mainButton = screen.getByRole("img").parentElement
expect(closeTrigger).toHaveClass("-top-1")
expect(closeTrigger).toHaveClass("left-0")
expect(closeTrigger).toHaveClass("invisible")
expect(closeTrigger).toHaveClass("group-hover:visible")
expect(closeTrigger).not.toHaveClass("hidden")
expect(closeTrigger).not.toHaveClass("group-hover:block")
expect(closeTrigger).toHaveClass("pointer-events-none")
expect(closeTrigger).toHaveClass("text-neutral-400")
expect(closeTrigger).toHaveClass("hover:scale-110")
expect(closeTrigger).toHaveClass("active:scale-90")
expect(closeTrigger).toHaveClass("hover:text-neutral-600")
expect(closeTrigger).toHaveClass("active:text-neutral-600")
fireEvent.mouseEnter(mainButton!)
expect(closeTrigger).toHaveClass("visible")
expect(closeTrigger).toHaveClass("pointer-events-auto")
expect(closeTrigger).toHaveClass("-left-6")
})
it("renders a lock trigger at the lower-left corner and keeps controls expanded after entering the main button", () => {
render(<FloatingButton />)
const lockTrigger = screen.getByRole("button", { name: "Lock floating button" })
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-400")
expect(lockTrigger).toHaveClass("hover:scale-110")
expect(lockTrigger).toHaveClass("active:scale-90")
expect(lockTrigger).toHaveClass("hover:text-neutral-600")
expect(lockTrigger).toHaveClass("active:text-neutral-600")
expect(mainButton).toHaveClass("translate-x-6")
fireEvent.mouseEnter(mainButton!)
expect(lockTrigger).toHaveClass("visible")
expect(lockTrigger).toHaveClass("pointer-events-auto")
expect(lockTrigger).toHaveClass("-left-6")
expect(mainButton).toHaveClass("translate-x-0")
fireEvent.click(lockTrigger)
const unlockTrigger = screen.getByRole("button", { name: "Unlock floating button" })
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!)
expect(mainButton).toHaveClass("translate-x-0")
expect(mainButton).toHaveClass("opacity-60")
fireEvent.mouseEnter(mainButton!)
expect(mainButton).toHaveClass("opacity-100")
})
it("forces the close trigger visible while the dropdown is open", () => {
render(<FloatingButton />)
const closeTrigger = screen.getByTitle("Close floating button")
const closeTrigger = screen.getByRole("button", { name: "Close floating button" })
const mainButton = screen.getByRole("img").parentElement
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()
})
})

View file

@ -5,17 +5,20 @@ export default function HiddenButton({
onClick,
children,
className,
expanded = false,
}: {
icon: React.ReactNode
onClick: () => void
children?: React.ReactNode
className?: string
expanded?: boolean
}) {
return (
<button
type="button"
className={cn(
"border-border mr-2 translate-x-12 cursor-pointer rounded-full border bg-white shadow-lg p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 group-hover:translate-x-0 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800",
"border-border mr-2 cursor-pointer rounded-full border bg-white shadow-lg p-1.5 text-neutral-600 dark:text-neutral-400 transition-transform duration-300 hover:bg-neutral-100 dark:bg-neutral-900 dark:hover:bg-neutral-800",
expanded ? "translate-x-0" : "translate-x-12",
className,
)}
onClick={onClick}

View file

@ -1,5 +1,5 @@
import { browser, i18n } from "#imports"
import { IconSettings, IconX } from "@tabler/icons-react"
import { IconLock, IconLockOpen, IconSettings, IconX } from "@tabler/icons-react"
import { useAtom, useAtomValue } from "jotai"
import { useEffect, useRef, useState } from "react"
import readFrogLogo from "@/assets/icons/read-frog.png?url&no-inline"
@ -22,6 +22,15 @@ import HiddenButton from "./components/hidden-button"
import TranslateButton from "./translate-button"
const readFrogLogoUrl = new URL(readFrogLogo, browser.runtime.getURL("/")).href
const floatingButtonControlClassName = cn(
"absolute invisible cursor-pointer pointer-events-none flex size-6 items-center justify-center",
"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 = {
collapsed: "left-0",
expanded: "-left-6",
}
export default function FloatingButton() {
const [floatingButton, setFloatingButton] = useAtom(
@ -32,8 +41,12 @@ export default function FloatingButton() {
const [isSideOpen, setIsSideOpen] = useAtom(isSideOpenAtom)
const [isDraggingButton, setIsDraggingButton] = useAtom(isDraggingButtonAtom)
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
const [isHitAreaExpanded, setIsHitAreaExpanded] = useState(false)
const [dragPosition, setDragPosition] = useState<number | null>(null)
const initialClientYRef = useRef<number | null>(null)
const isFloatingButtonLocked = floatingButton.locked
const isFloatingButtonExpanded = isHitAreaExpanded || isDraggingButton || isSideOpen || isDropdownOpen
const isMainButtonAttached = isFloatingButtonLocked || isFloatingButtonExpanded
// 按钮拖动处理
useEffect(() => {
@ -125,80 +138,63 @@ export default function FloatingButton() {
const attachSideClassName = isDraggingButton || isSideOpen || isDropdownOpen ? "translate-x-0" : ""
const handleMouseEnter = () => {
setIsHitAreaExpanded(true)
}
const handleMouseLeave = () => {
if (!isDropdownOpen) {
setIsHitAreaExpanded(false)
}
}
if (!floatingButton.enabled || floatingButton.disabledFloatingButtonPatterns.some(pattern => matchDomainPattern(window.location.href, pattern))) {
return null
}
return (
<div
className="group fixed z-2147483647 flex flex-col items-end gap-2 print:hidden"
className={cn(
"fixed z-2147483647 flex flex-col items-end gap-2 print:hidden",
isFloatingButtonExpanded && "pl-6",
)}
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}
>
<TranslateButton className={attachSideClassName} />
<div
className={cn(
"border-border flex h-10 w-11 items-center rounded-l-full border border-r-0 bg-white opacity-60 shadow-lg group-hover:opacity-100 dark:bg-neutral-900",
"translate-x-6 transition-transform duration-300 group-hover:translate-x-0",
(isSideOpen || isDropdownOpen) && "opacity-100",
isDraggingButton ? "cursor-move" : "cursor-pointer",
attachSideClassName,
)}
onMouseDown={handleButtonDragStart}
>
<DropdownMenu open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
<DropdownMenuTrigger
render={(
<button
type="button"
title="Close floating button"
className={cn(
"border-border absolute -top-1 -left-1 invisible cursor-pointer rounded-full border bg-neutral-100 dark:bg-neutral-900",
"group-hover:visible",
isDropdownOpen && "visible",
)}
onMouseDown={e => e.stopPropagation()} // 父级不会收到 mousedown
/>
)}
>
<IconX className="h-3 w-3 text-neutral-400 dark:text-neutral-600" />
</DropdownMenuTrigger>
<DropdownMenuContent container={shadowWrapper} align="start" side="left" className="z-2147483647 w-fit! whitespace-nowrap">
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={() => {
const currentDomain = window.location.hostname
const currentPatterns = floatingButton.disabledFloatingButtonPatterns || []
void setFloatingButton({
...floatingButton,
disabledFloatingButtonPatterns: [...currentPatterns, currentDomain],
})
}}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")}
</DropdownMenuItem>
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={() => {
void setFloatingButton({ ...floatingButton, enabled: false })
}}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableGlobally")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<img
src={readFrogLogoUrl}
alt={APP_NAME}
className="ml-1 h-8 w-8 rounded-full"
<TranslateButton className={attachSideClassName} expanded={isFloatingButtonExpanded} />
<div className="relative">
<div
className={cn(
"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,
)}
onMouseDown={handleButtonDragStart}
onMouseEnter={handleMouseEnter}
>
<img
src={readFrogLogoUrl}
alt={APP_NAME}
className="ml-1 h-8 w-8 rounded-full"
/>
</div>
<FloatingButtonCloseMenu
expanded={isFloatingButtonExpanded}
onDropdownOpenChange={setIsDropdownOpen}
/>
<FloatingButtonLockControl expanded={isFloatingButtonExpanded} />
</div>
<HiddenButton
className={attachSideClassName}
expanded={isFloatingButtonExpanded}
icon={<IconSettings className="h-5 w-5" />}
onClick={() => {
void sendMessage("openOptionsPage", undefined)
@ -207,3 +203,108 @@ export default function FloatingButton() {
</div>
)
}
interface FloatingButtonCloseMenuProps {
expanded: boolean
onDropdownOpenChange: (open: boolean) => void
}
function FloatingButtonCloseMenu({
expanded,
onDropdownOpenChange,
}: FloatingButtonCloseMenuProps) {
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
const [open, setOpen] = useState(false)
const controlOffsetClassName = !floatingButton.locked && !expanded
? floatingButtonControlOffsetClassNames.collapsed
: floatingButtonControlOffsetClassNames.expanded
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
onDropdownOpenChange(nextOpen)
}
const handleDisableForSite = () => {
const currentDomain = window.location.hostname
const currentPatterns = floatingButton.disabledFloatingButtonPatterns || []
void setFloatingButton({
...floatingButton,
disabledFloatingButtonPatterns: [...currentPatterns, currentDomain],
})
}
const handleDisableGlobally = () => {
void setFloatingButton({ ...floatingButton, enabled: false })
}
return (
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
render={(
<button
type="button"
aria-label="Close floating button"
className={cn(
floatingButtonControlClassName,
"-top-1",
controlOffsetClassName,
expanded && "visible pointer-events-auto",
open && "visible pointer-events-auto",
)}
/>
)}
>
<IconX className="h-3 w-3" strokeWidth={3} />
</DropdownMenuTrigger>
<DropdownMenuContent container={shadowWrapper} align="start" side="left" className="z-2147483647 w-fit! whitespace-nowrap">
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={handleDisableForSite}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableForSite")}
</DropdownMenuItem>
<DropdownMenuItem
onMouseDown={e => e.stopPropagation()}
onClick={handleDisableGlobally}
>
{i18n.t("options.floatingButtonAndToolbar.floatingButton.closeMenu.disableGlobally")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface FloatingButtonLockControlProps {
expanded: boolean
}
function FloatingButtonLockControl({ expanded }: FloatingButtonLockControlProps) {
const [floatingButton, setFloatingButton] = useAtom(configFieldsAtomMap.floatingButton)
const locked = floatingButton.locked
const controlOffsetClassName = !locked && !expanded
? floatingButtonControlOffsetClassNames.collapsed
: floatingButtonControlOffsetClassNames.expanded
const handleToggleLocked = () => {
void setFloatingButton({ ...floatingButton, locked: !locked })
}
return (
<button
type="button"
aria-label={locked ? "Unlock floating button" : "Lock floating button"}
className={cn(
floatingButtonControlClassName,
"-bottom-1",
controlOffsetClassName,
expanded && "visible pointer-events-auto",
)}
onClick={handleToggleLocked}
>
{locked
? <IconLock className="h-3 w-3" strokeWidth={3} />
: <IconLockOpen className="h-3 w-3" strokeWidth={3} />}
</button>
)
}

View file

@ -6,7 +6,7 @@ import { cn } from "@/utils/styles/utils"
import { enablePageTranslationAtom } from "../../atoms"
import HiddenButton from "./components/hidden-button"
export default function TranslateButton({ className }: { className: string }) {
export default function TranslateButton({ className, expanded = false }: { className: string, expanded?: boolean }) {
const translationState = useAtomValue(enablePageTranslationAtom)
const isEnabled = translationState.enabled
@ -14,6 +14,7 @@ export default function TranslateButton({ className }: { className: string }) {
<HiddenButton
icon={<RiTranslate className="h-5 w-5" />}
className={className}
expanded={expanded}
onClick={() => {
void sendMessage("tryToSetEnablePageTranslationOnContentScript", { enabled: !isEnabled })
}}

View file

@ -275,7 +275,7 @@ options:
title: 启用悬浮按钮
description: 在网页上显示悬浮按钮,快速访问陪读蛙功能
closeMenu:
disableForSite: 禁用此网站
disableForSite: 当前网站禁用
disableGlobally: 全局禁用
disabledSites:
title: 悬浮按钮禁用网站
@ -300,7 +300,7 @@ options:
translate: 翻译
speak: 朗读
closeMenu:
disableForSite: 禁用此网站
disableForSite: 当前网站禁用
disableGlobally: 全局禁用
disabledSites:
title: 选择工具栏禁用网站

View file

@ -26,6 +26,7 @@ const floatingButtonSchema = z.object({
position: z.number().min(0).max(1),
disabledFloatingButtonPatterns: z.array(z.string()),
clickAction: z.enum(["panel", "translate"]),
locked: z.boolean(),
})
const selectionToolbarFeatureSchema = z.object({

View file

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

View file

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

View file

@ -0,0 +1,17 @@
/**
* Migration script from v067 to v068
* - Adds `floatingButton.locked` with a default value of `false`
*
* 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,
locked: oldConfig?.floatingButton?.locked ?? false,
},
}
}

View file

@ -18,7 +18,7 @@ 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 = 67
export const CONFIG_SCHEMA_VERSION = 68
export const DEFAULT_FLOATING_BUTTON_POSITION = 0.66
@ -95,6 +95,7 @@ export const DEFAULT_CONFIG: Config = {
position: DEFAULT_FLOATING_BUTTON_POSITION,
disabledFloatingButtonPatterns: [],
clickAction: "translate",
locked: false,
},
selectionToolbar: {
enabled: true,