mirror of
https://github.com/mengxi-ream/read-frog.git
synced 2026-08-30 17:48:58 +08:00
fix(selection): support bridged ebook selections (#1793)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
fix(selection): support bridged ebook reader selections
|
||||
@@ -6,8 +6,19 @@ function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Prop
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
function Tooltip({ disableHoverablePopup = true, ...props }: TooltipPrimitive.Root.Props) {
|
||||
// Hoverable popups close via a safe-polygon that tracks the top document's
|
||||
// mousemove; a pointer leaving the popup onto an iframe (e.g. the readfrog
|
||||
// ebook reader, embedded videos) stops those events and the tooltip sticks
|
||||
// open forever. All our tooltips are plain labels, so opt out by default —
|
||||
// closing then only relies on the trigger's own mouseleave.
|
||||
return (
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
disableHoverablePopup={disableHoverablePopup}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { EbookBridgeSelectionPayload } from "@read-frog/definitions"
|
||||
import {
|
||||
EBOOK_BRIDGE_EXTENSION_SOURCE,
|
||||
EBOOK_BRIDGE_HANDSHAKE_TYPE,
|
||||
EBOOK_BRIDGE_PAGE_SOURCE,
|
||||
EBOOK_BRIDGE_SELECTION_CHANGED_TYPE,
|
||||
EBOOK_BRIDGE_SELECTION_CLEARED_TYPE,
|
||||
EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
} from "@read-frog/definitions"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
EXTERNAL_SELECTION_CLEAR_EVENT,
|
||||
EXTERNAL_SELECTION_OPEN_EVENT,
|
||||
} from "@/utils/constants/selection"
|
||||
import { setupExternalSelectionSource } from "../external-selection-source"
|
||||
|
||||
const mockOfficialOrigins = vi.hoisted(() => ({ value: [] as string[] }))
|
||||
|
||||
vi.mock("@/env", () => ({
|
||||
env: {
|
||||
get WXT_OFFICIAL_SITE_ORIGINS() {
|
||||
return mockOfficialOrigins.value
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const SELECTION_PAYLOAD: EbookBridgeSelectionPayload = {
|
||||
requestId: "req-1",
|
||||
text: "Selected ebook text",
|
||||
contextParagraphs: ["Paragraph one", "Paragraph two"],
|
||||
rect: { top: 120, left: 40, width: 200, height: 18 },
|
||||
anchor: { x: 240, y: 138 },
|
||||
direction: "bottom-right",
|
||||
bookTitle: "Some Book",
|
||||
}
|
||||
|
||||
const HANDSHAKE_MESSAGE = {
|
||||
source: EBOOK_BRIDGE_PAGE_SOURCE,
|
||||
protocolVersion: EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
type: EBOOK_BRIDGE_HANDSHAKE_TYPE,
|
||||
}
|
||||
|
||||
const SELECTION_CHANGED_MESSAGE = {
|
||||
source: EBOOK_BRIDGE_PAGE_SOURCE,
|
||||
protocolVersion: EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
type: EBOOK_BRIDGE_SELECTION_CHANGED_TYPE,
|
||||
data: SELECTION_PAYLOAD,
|
||||
}
|
||||
|
||||
const SELECTION_CLEARED_MESSAGE = {
|
||||
source: EBOOK_BRIDGE_PAGE_SOURCE,
|
||||
protocolVersion: EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
type: EBOOK_BRIDGE_SELECTION_CLEARED_TYPE,
|
||||
}
|
||||
|
||||
function dispatchPageMessage(data: unknown, source: MessageEventSource | null = window) {
|
||||
window.dispatchEvent(new MessageEvent("message", { data, source }))
|
||||
}
|
||||
|
||||
describe("setupExternalSelectionSource", () => {
|
||||
let teardown: (() => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
mockOfficialOrigins.value = [window.location.origin]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
teardown?.()
|
||||
teardown = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("returns null when the origin is not an official site origin", () => {
|
||||
mockOfficialOrigins.value = ["https://readfrog.app"]
|
||||
const postMessageSpy = vi.spyOn(window, "postMessage")
|
||||
|
||||
teardown = setupExternalSelectionSource()
|
||||
|
||||
expect(teardown).toBeNull()
|
||||
|
||||
dispatchPageMessage(HANDSHAKE_MESSAGE)
|
||||
expect(postMessageSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("registers on an official origin and replies to the page handshake", () => {
|
||||
teardown = setupExternalSelectionSource()
|
||||
expect(teardown).not.toBeNull()
|
||||
|
||||
const postMessageSpy = vi.spyOn(window, "postMessage")
|
||||
dispatchPageMessage(HANDSHAKE_MESSAGE)
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledTimes(1)
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
source: EBOOK_BRIDGE_EXTENSION_SOURCE,
|
||||
type: EBOOK_BRIDGE_HANDSHAKE_TYPE,
|
||||
protocolVersion: EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
data: { extensionVersion: "1.0.0" },
|
||||
},
|
||||
window.location.origin,
|
||||
)
|
||||
})
|
||||
|
||||
it("ignores messages whose source is not this window", () => {
|
||||
teardown = setupExternalSelectionSource()
|
||||
|
||||
const openListener = vi.fn<(event: Event) => void>()
|
||||
window.addEventListener(EXTERNAL_SELECTION_OPEN_EVENT, openListener)
|
||||
const postMessageSpy = vi.spyOn(window, "postMessage")
|
||||
|
||||
dispatchPageMessage(HANDSHAKE_MESSAGE, null)
|
||||
dispatchPageMessage(SELECTION_CHANGED_MESSAGE, null)
|
||||
|
||||
expect(postMessageSpy).not.toHaveBeenCalled()
|
||||
expect(openListener).not.toHaveBeenCalled()
|
||||
|
||||
window.removeEventListener(EXTERNAL_SELECTION_OPEN_EVENT, openListener)
|
||||
})
|
||||
|
||||
it("silently ignores schema-invalid payloads", () => {
|
||||
teardown = setupExternalSelectionSource()
|
||||
|
||||
const openListener = vi.fn<(event: Event) => void>()
|
||||
const clearListener = vi.fn<(event: Event) => void>()
|
||||
window.addEventListener(EXTERNAL_SELECTION_OPEN_EVENT, openListener)
|
||||
window.addEventListener(EXTERNAL_SELECTION_CLEAR_EVENT, clearListener)
|
||||
const postMessageSpy = vi.spyOn(window, "postMessage")
|
||||
|
||||
dispatchPageMessage("junk")
|
||||
dispatchPageMessage({ source: EBOOK_BRIDGE_PAGE_SOURCE })
|
||||
dispatchPageMessage({
|
||||
source: EBOOK_BRIDGE_PAGE_SOURCE,
|
||||
protocolVersion: EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
type: "unknownFutureType",
|
||||
})
|
||||
dispatchPageMessage({
|
||||
source: EBOOK_BRIDGE_PAGE_SOURCE,
|
||||
protocolVersion: EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
type: EBOOK_BRIDGE_SELECTION_CHANGED_TYPE,
|
||||
})
|
||||
|
||||
expect(postMessageSpy).not.toHaveBeenCalled()
|
||||
expect(openListener).not.toHaveBeenCalled()
|
||||
expect(clearListener).not.toHaveBeenCalled()
|
||||
|
||||
window.removeEventListener(EXTERNAL_SELECTION_OPEN_EVENT, openListener)
|
||||
window.removeEventListener(EXTERNAL_SELECTION_CLEAR_EVENT, clearListener)
|
||||
})
|
||||
|
||||
it("dispatches the external open event with the selection payload", () => {
|
||||
teardown = setupExternalSelectionSource()
|
||||
|
||||
const openListener = vi.fn<(event: Event) => void>()
|
||||
window.addEventListener(EXTERNAL_SELECTION_OPEN_EVENT, openListener)
|
||||
|
||||
dispatchPageMessage(SELECTION_CHANGED_MESSAGE)
|
||||
|
||||
expect(openListener).toHaveBeenCalledTimes(1)
|
||||
const event = openListener.mock.calls[0]![0] as CustomEvent<EbookBridgeSelectionPayload>
|
||||
expect(event.detail).toEqual(SELECTION_PAYLOAD)
|
||||
|
||||
window.removeEventListener(EXTERNAL_SELECTION_OPEN_EVENT, openListener)
|
||||
})
|
||||
|
||||
it("dispatches the external clear event when the selection is cleared", () => {
|
||||
teardown = setupExternalSelectionSource()
|
||||
|
||||
const clearListener = vi.fn<(event: Event) => void>()
|
||||
window.addEventListener(EXTERNAL_SELECTION_CLEAR_EVENT, clearListener)
|
||||
|
||||
dispatchPageMessage(SELECTION_CLEARED_MESSAGE)
|
||||
|
||||
expect(clearListener).toHaveBeenCalledTimes(1)
|
||||
|
||||
window.removeEventListener(EXTERNAL_SELECTION_CLEAR_EVENT, clearListener)
|
||||
})
|
||||
|
||||
it("stops handling messages after cleanup", () => {
|
||||
teardown = setupExternalSelectionSource()
|
||||
const postMessageSpy = vi.spyOn(window, "postMessage")
|
||||
|
||||
teardown?.()
|
||||
teardown = null
|
||||
|
||||
dispatchPageMessage(HANDSHAKE_MESSAGE)
|
||||
expect(postMessageSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type {
|
||||
EbookBridgeExtensionHandshake,
|
||||
EbookBridgeSelectionPayload,
|
||||
} from "@read-frog/definitions"
|
||||
import {
|
||||
EBOOK_BRIDGE_EXTENSION_SOURCE,
|
||||
EBOOK_BRIDGE_HANDSHAKE_TYPE,
|
||||
EBOOK_BRIDGE_SELECTION_CHANGED_TYPE,
|
||||
EBOOK_BRIDGE_SELECTION_CLEARED_TYPE,
|
||||
EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
ebookBridgePageMessageSchema,
|
||||
} from "@read-frog/definitions"
|
||||
import { browser } from "#imports"
|
||||
import { env } from "@/env"
|
||||
import {
|
||||
EXTERNAL_SELECTION_CLEAR_EVENT,
|
||||
EXTERNAL_SELECTION_OPEN_EVENT,
|
||||
} from "@/utils/constants/selection"
|
||||
|
||||
function replyToHandshake() {
|
||||
const reply: EbookBridgeExtensionHandshake = {
|
||||
source: EBOOK_BRIDGE_EXTENSION_SOURCE,
|
||||
type: EBOOK_BRIDGE_HANDSHAKE_TYPE,
|
||||
protocolVersion: EBOOK_SELECTION_BRIDGE_PROTOCOL_VERSION,
|
||||
data: {
|
||||
extensionVersion: browser.runtime.getManifest().version,
|
||||
},
|
||||
}
|
||||
|
||||
window.postMessage(reply, window.location.origin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes ebook selection-bridge messages relayed by the readfrog.app reader
|
||||
* page (the reader detects selections inside the book iframe, which this
|
||||
* top-frame content script cannot observe) and re-emits them as window
|
||||
* CustomEvents for the selection toolbar.
|
||||
*/
|
||||
export function setupExternalSelectionSource(): (() => void) | null {
|
||||
if (!env.WXT_OFFICIAL_SITE_ORIGINS.includes(window.location.origin)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (window.top !== window) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.source !== window) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = ebookBridgePageMessageSchema.safeParse(e.data)
|
||||
// Silently ignore unrelated postMessage traffic and unknown message types
|
||||
// (the protocol requires receivers to ignore, not throw on, unknown input)
|
||||
if (!parsed.success) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (parsed.data.type) {
|
||||
case EBOOK_BRIDGE_HANDSHAKE_TYPE:
|
||||
replyToHandshake()
|
||||
break
|
||||
case EBOOK_BRIDGE_SELECTION_CHANGED_TYPE:
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<EbookBridgeSelectionPayload>(EXTERNAL_SELECTION_OPEN_EVENT, {
|
||||
detail: parsed.data.data,
|
||||
}),
|
||||
)
|
||||
break
|
||||
case EBOOK_BRIDGE_SELECTION_CLEARED_TYPE:
|
||||
window.dispatchEvent(new CustomEvent(EXTERNAL_SELECTION_CLEAR_EVENT))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { queryClient } from "@/utils/tanstack-query"
|
||||
import { getLocalThemeMode } from "@/utils/theme"
|
||||
import App from "./app"
|
||||
import { setupExternalSelectionSource } from "./external-selection-source"
|
||||
import "@/assets/styles/theme.css"
|
||||
|
||||
function HydrateAtoms({
|
||||
@@ -111,6 +112,12 @@ export default defineContentScript({
|
||||
return
|
||||
}
|
||||
|
||||
// Answer ebook bridge handshakes before the React UI finishes mounting
|
||||
const cleanupExternalSelectionSource = setupExternalSelectionSource()
|
||||
if (cleanupExternalSelectionSource) {
|
||||
ctx.onInvalidated(cleanupExternalSelectionSource)
|
||||
}
|
||||
|
||||
await initI18n(config?.uiLanguage)
|
||||
|
||||
void mountSelectionUI(ctx)
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { EbookBridgeSelectionPayload } from "@read-frog/definitions"
|
||||
import { act, cleanup, render, waitFor } from "@testing-library/react"
|
||||
import { atom, getDefaultStore } from "jotai"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
EXTERNAL_SELECTION_CLEAR_EVENT,
|
||||
EXTERNAL_SELECTION_OPEN_EVENT,
|
||||
} from "@/utils/constants/selection"
|
||||
import { isSelectionToolbarVisibleAtom, selectionSessionAtom } from "../atoms"
|
||||
import { SelectionToolbar } from "../index"
|
||||
|
||||
// Mock child components
|
||||
vi.mock("../translate-button", () => ({
|
||||
TranslateButton: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("../speak-button", () => ({
|
||||
SpeakButton: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("../custom-action-button", () => ({
|
||||
SelectionToolbarCustomActionButtons: () => null,
|
||||
}))
|
||||
|
||||
// Mock atoms
|
||||
vi.mock("@/utils/atoms/config", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/utils/atoms/config")>()
|
||||
return {
|
||||
...actual,
|
||||
configFieldsAtomMap: {
|
||||
...actual.configFieldsAtomMap,
|
||||
selectionToolbar: atom({
|
||||
enabled: true,
|
||||
disabledSelectionToolbarPatterns: [],
|
||||
opacity: 100,
|
||||
features: {
|
||||
translate: {
|
||||
enabled: true,
|
||||
providerId: "microsoft-translate-default",
|
||||
shortcut: "Alt+T",
|
||||
},
|
||||
speak: { enabled: true },
|
||||
},
|
||||
customActions: [],
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const EXTERNAL_PAYLOAD: EbookBridgeSelectionPayload = {
|
||||
requestId: "req-1",
|
||||
text: "Selected ebook text",
|
||||
contextParagraphs: ["Paragraph one", "Paragraph two"],
|
||||
rect: { top: 120, left: 40, width: 200, height: 18 },
|
||||
anchor: { x: 240, y: 138 },
|
||||
direction: "bottom-right",
|
||||
bookTitle: "Some Book",
|
||||
}
|
||||
|
||||
async function dispatchExternalOpen(payload: EbookBridgeSelectionPayload) {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent(EXTERNAL_SELECTION_OPEN_EVENT, { detail: payload }))
|
||||
})
|
||||
}
|
||||
|
||||
async function dispatchExternalClear() {
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent(EXTERNAL_SELECTION_CLEAR_EVENT))
|
||||
})
|
||||
}
|
||||
|
||||
function expectToolbarVisible() {
|
||||
expect(document.querySelector(".absolute.z-2147483647")).toHaveClass("opacity-100")
|
||||
}
|
||||
|
||||
function expectToolbarHidden() {
|
||||
expect(document.querySelector(".absolute.z-2147483647")).toHaveClass("opacity-0")
|
||||
}
|
||||
|
||||
describe("selectionToolbar - external selection source", () => {
|
||||
const store = getDefaultStore()
|
||||
|
||||
beforeEach(() => {
|
||||
store.set(selectionSessionAtom, null)
|
||||
store.set(isSelectionToolbarVisibleAtom, false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("shows the toolbar and builds a session from the external payload", async () => {
|
||||
render(<SelectionToolbar />)
|
||||
|
||||
await dispatchExternalOpen(EXTERNAL_PAYLOAD)
|
||||
await waitFor(expectToolbarVisible)
|
||||
|
||||
const session = store.get(selectionSessionAtom)
|
||||
expect(session?.selectionSnapshot.text).toBe(EXTERNAL_PAYLOAD.text)
|
||||
expect(session?.selectionSnapshot.ranges).toEqual([])
|
||||
expect(session?.contextSnapshot.paragraphs).toEqual(EXTERNAL_PAYLOAD.contextParagraphs)
|
||||
expect(session?.contextSnapshot.text).toBe(EXTERNAL_PAYLOAD.contextParagraphs.join("\n\n"))
|
||||
})
|
||||
|
||||
it("falls back to the selected text when no context paragraphs are provided", async () => {
|
||||
render(<SelectionToolbar />)
|
||||
|
||||
await dispatchExternalOpen({ ...EXTERNAL_PAYLOAD, contextParagraphs: [] })
|
||||
await waitFor(expectToolbarVisible)
|
||||
|
||||
const session = store.get(selectionSessionAtom)
|
||||
expect(session?.contextSnapshot.paragraphs).toEqual([EXTERNAL_PAYLOAD.text])
|
||||
expect(session?.contextSnapshot.text).toBe(EXTERNAL_PAYLOAD.text)
|
||||
})
|
||||
|
||||
it("repositions repeated selections using top-frame viewport coordinates", async () => {
|
||||
vi.spyOn(window, "scrollX", "get").mockReturnValue(100)
|
||||
vi.spyOn(window, "scrollY", "get").mockReturnValue(200)
|
||||
render(<SelectionToolbar />)
|
||||
|
||||
await dispatchExternalOpen(EXTERNAL_PAYLOAD)
|
||||
|
||||
const toolbar = document.querySelector<HTMLElement>(".absolute.z-2147483647")
|
||||
await waitFor(() => {
|
||||
expect(toolbar).toHaveStyle({ left: "240px", top: "158px" })
|
||||
})
|
||||
|
||||
await dispatchExternalOpen({
|
||||
...EXTERNAL_PAYLOAD,
|
||||
requestId: "req-2",
|
||||
anchor: { x: 80, y: 90 },
|
||||
direction: "top-left",
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toolbar).toHaveStyle({ left: "80px", top: "70px" })
|
||||
})
|
||||
})
|
||||
|
||||
it("hides the toolbar and clears the session on the external clear event", async () => {
|
||||
render(<SelectionToolbar />)
|
||||
|
||||
await dispatchExternalOpen(EXTERNAL_PAYLOAD)
|
||||
await waitFor(expectToolbarVisible)
|
||||
|
||||
await dispatchExternalClear()
|
||||
|
||||
expectToolbarHidden()
|
||||
expect(store.get(selectionSessionAtom)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,21 @@
|
||||
import type {
|
||||
EbookBridgeSelectionDirection,
|
||||
EbookBridgeSelectionPayload,
|
||||
} from "@read-frog/definitions"
|
||||
import type { ModalDialogHostController } from "./modal-dialog-host"
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react"
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
|
||||
import {
|
||||
SELECTION_CONTENT_OVERLAY_LAYERS,
|
||||
SELECTION_CONTENT_OVERLAY_ROOT_ATTRIBUTE,
|
||||
} from "@/entrypoints/selection.content/overlay-layers"
|
||||
import { configFieldsAtomMap } from "@/utils/atoms/config"
|
||||
import { NOTRANSLATE_CLASS } from "@/utils/constants/dom-labels"
|
||||
import { MARGIN } from "@/utils/constants/selection"
|
||||
import {
|
||||
EXTERNAL_SELECTION_CLEAR_EVENT,
|
||||
EXTERNAL_SELECTION_OPEN_EVENT,
|
||||
MARGIN,
|
||||
} from "@/utils/constants/selection"
|
||||
import { cn } from "@/utils/styles/utils"
|
||||
import { matchDomainPattern } from "@/utils/url"
|
||||
import { buildContextSnapshot, readSelectionSnapshot } from "../utils"
|
||||
@@ -32,6 +40,14 @@ import {
|
||||
import { SpeakButton } from "./speak-button"
|
||||
import { TranslateButton } from "./translate-button"
|
||||
|
||||
const EXTERNAL_SELECTION_DIRECTION_MAP: Record<EbookBridgeSelectionDirection, SelectionDirection> =
|
||||
{
|
||||
"top-left": SelectionDirection.TOP_LEFT,
|
||||
"top-right": SelectionDirection.TOP_RIGHT,
|
||||
"bottom-left": SelectionDirection.BOTTOM_LEFT,
|
||||
"bottom-right": SelectionDirection.BOTTOM_RIGHT,
|
||||
}
|
||||
|
||||
const SELECTION_GUARD_INTERACTIVE_SELECTOR = [
|
||||
"button",
|
||||
'[role="button"]',
|
||||
@@ -197,6 +213,9 @@ export function SelectionToolbar() {
|
||||
const clearSelectionState = useSetAtom(clearSelectionStateAtom)
|
||||
const selectionToolbar = useAtomValue(configFieldsAtomMap.selectionToolbar)
|
||||
const dropdownOpenRef = useRef(false)
|
||||
// Bumped per external (ebook bridge) selection so the position is re-applied
|
||||
// even when the toolbar is already visible (visibility doesn't flip then).
|
||||
const [externalSelectionTick, setExternalSelectionTick] = useState(0)
|
||||
|
||||
const placeHostForSelection = useCallback(
|
||||
(ranges: Parameters<ModalDialogHostController["placeForRanges"]>[0]) => {
|
||||
@@ -274,7 +293,7 @@ export function SelectionToolbar() {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
updatePosition({ remeasureSelection: true })
|
||||
}, [updatePosition])
|
||||
}, [updatePosition, externalSelectionTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelectionToolbarVisible) {
|
||||
@@ -486,6 +505,61 @@ export function SelectionToolbar() {
|
||||
return () => window.removeEventListener(DropEvent, handler)
|
||||
}, [])
|
||||
|
||||
// External selections (e.g. the readfrog.app ebook reader) relay in-book
|
||||
// selections that never touch this frame's Selection API, so they enter
|
||||
// through CustomEvents and reuse the same state mutations as handleMouseUp
|
||||
useEffect(() => {
|
||||
const handleExternalSelectionOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<EbookBridgeSelectionPayload>).detail
|
||||
if (!detail) {
|
||||
return
|
||||
}
|
||||
|
||||
const paragraphs =
|
||||
detail.contextParagraphs.length > 0 ? detail.contextParagraphs : [detail.text]
|
||||
|
||||
preserveSelectionStateRef.current = false
|
||||
setSelectionState({
|
||||
selection: { text: detail.text, ranges: [] },
|
||||
context: {
|
||||
text: paragraphs.join("\n\n"),
|
||||
paragraphs,
|
||||
},
|
||||
})
|
||||
selectionDirectionRef.current = EXTERNAL_SELECTION_DIRECTION_MAP[detail.direction]
|
||||
selectionPositionRef.current = detail.anchor
|
||||
selectionAnchorTrackerRef.current = null
|
||||
selectionScrollTargetsRef.current = []
|
||||
setIsSelectionToolbarVisible(true)
|
||||
// Force a reposition: visibility may already be true, in which case the
|
||||
// updatePosition layout effect would not re-run on its own.
|
||||
setExternalSelectionTick((tick) => tick + 1)
|
||||
}
|
||||
|
||||
const handleExternalSelectionClear = () => {
|
||||
// Bridged clears only fire for in-book actions (mousedown, collapsed
|
||||
// selection, page turn) — the same intent as a top-frame mousedown
|
||||
// outside the overlay, so reset the preserve flag like handleMouseDown
|
||||
// does instead of letting a stale flag swallow the dismissal.
|
||||
preserveSelectionStateRef.current = false
|
||||
|
||||
clearSelectionState()
|
||||
selectionPositionRef.current = null
|
||||
selectionAnchorTrackerRef.current = null
|
||||
selectionScrollTargetsRef.current = []
|
||||
// Don't hide toolbar when dropdown is open to prevent unwanted dismissal
|
||||
if (!dropdownOpenRef.current) setIsSelectionToolbarVisible(false)
|
||||
}
|
||||
|
||||
window.addEventListener(EXTERNAL_SELECTION_OPEN_EVENT, handleExternalSelectionOpen)
|
||||
window.addEventListener(EXTERNAL_SELECTION_CLEAR_EVENT, handleExternalSelectionClear)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(EXTERNAL_SELECTION_OPEN_EVENT, handleExternalSelectionOpen)
|
||||
window.removeEventListener(EXTERNAL_SELECTION_CLEAR_EVENT, handleExternalSelectionClear)
|
||||
}
|
||||
}, [clearSelectionState, setIsSelectionToolbarVisible, setSelectionState])
|
||||
|
||||
// Check if current site is disabled
|
||||
const isSiteDisabled = selectionToolbar.disabledSelectionToolbarPatterns?.some((pattern) =>
|
||||
matchDomainPattern(window.location.href, pattern),
|
||||
|
||||
@@ -3,3 +3,8 @@ export const MARGIN = 25
|
||||
export const MIN_SELECTION_OVERLAY_OPACITY = 1
|
||||
export const MAX_SELECTION_OVERLAY_OPACITY = 100
|
||||
export const DEFAULT_SELECTION_OVERLAY_OPACITY = 100
|
||||
|
||||
/** Fired when an external source (e.g. the readfrog.app ebook reader) relays a selection. */
|
||||
export const EXTERNAL_SELECTION_OPEN_EVENT = "read-frog:external-selection-open"
|
||||
/** Fired when an external source dismisses its relayed selection. */
|
||||
export const EXTERNAL_SELECTION_CLEAR_EVENT = "read-frog:external-selection-clear"
|
||||
|
||||
+8
-8
@@ -13,6 +13,12 @@ const WXT_API_KEY_PATTERN = /^WXT_.*API_KEY/
|
||||
const ALLOWED_BUNDLED_API_KEYS = new Set(["WXT_POSTHOG_API_KEY"])
|
||||
const useLocalPackages = isLocalPackagesEnabled(process.env)
|
||||
const shouldSkipEnvValidation = process.env.WXT_SKIP_ENV_VALIDATION === "true"
|
||||
// Root of the read-frog monorepo whose source is aliased in when developing
|
||||
// with local packages. Defaults to the sibling checkout; override with
|
||||
// WXT_MONOREPO_PATH to point at a git worktree (relative or absolute).
|
||||
const monorepoRoot = process.env.WXT_MONOREPO_PATH
|
||||
? path.resolve(process.env.WXT_MONOREPO_PATH)
|
||||
: path.resolve(__dirname, "../read-frog-monorepo")
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
@@ -23,14 +29,8 @@ export default defineConfig({
|
||||
// WXT top level alias - will be automatically synced to tsconfig.json paths and Vite alias
|
||||
alias: useLocalPackages
|
||||
? {
|
||||
"@read-frog/definitions": path.resolve(
|
||||
__dirname,
|
||||
"../read-frog-monorepo/packages/definitions/src",
|
||||
),
|
||||
"@read-frog/api-contract": path.resolve(
|
||||
__dirname,
|
||||
"../read-frog-monorepo/packages/api-contract/src",
|
||||
),
|
||||
"@read-frog/definitions": path.resolve(monorepoRoot, "packages/definitions/src"),
|
||||
"@read-frog/api-contract": path.resolve(monorepoRoot, "packages/api-contract/src"),
|
||||
}
|
||||
: {},
|
||||
manifest: ({ mode, browser }) => ({
|
||||
|
||||
Reference in New Issue
Block a user