fix(selection): support bridged ebook selections (#1793)

This commit is contained in:
MengXi
2026-07-27 14:14:42 -07:00
committed by GitHub
parent e35b2c0044
commit 686c8ffebf
9 changed files with 541 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@read-frog/extension": patch
---
fix(selection): support bridged ebook reader selections
+13 -2
View File
@@ -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)
@@ -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),
+5
View File
@@ -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
View File
@@ -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 }) => ({