fix(desktop): add Eagle image context menu

This commit is contained in:
DIYgod 2026-05-12 17:14:30 +08:00
parent 4be035d37e
commit 13cc719377
13 changed files with 206 additions and 10 deletions

View File

@ -0,0 +1,105 @@
import * as React from "react"
import { act } from "react"
import type { Root } from "react-dom/client"
import { createRoot } from "react-dom/client"
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"
import { MarkdownRenderActionContext } from "../context"
import { MarkdownBlockImage } from "./BlockImage"
const { mediaMock, getWrappedElementSizeMock } = vi.hoisted(() => ({
mediaMock: vi.fn(
({
blurhash: _blurhash,
mediaContainerClassName: _mediaContainerClassName,
popper: _popper,
proxy: _proxy,
showFallback: _showFallback,
type: _type,
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & Record<string, unknown>) => (
<img alt="" data-testid="media" {...props} />
),
),
getWrappedElementSizeMock: vi.fn(() => ({ h: 0, w: 640 })),
}))
vi.mock("../../media/Media", () => ({
Media: mediaMock,
}))
vi.mock("~/providers/wrapped-element-provider", () => ({
useWrappedElementSize: getWrappedElementSizeMock,
}))
const renderComponent = async (element: React.ReactNode) => {
const container = document.createElement("div")
document.body.append(container)
const root = createRoot(container)
await act(async () => {
root.render(element)
})
return { container, root }
}
describe("MarkdownBlockImage", () => {
let root: Root | null = null
let container: HTMLElement | null = null
beforeAll(() => {
;(globalThis as typeof globalThis & { React: typeof React }).React = React
;(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(async () => {
if (root) {
await act(async () => {
root?.unmount()
})
}
container?.remove()
root = null
container = null
vi.clearAllMocks()
})
test("passes image context menu events with the resolved image URL", async () => {
const onImageContextMenu = vi.fn()
;({ container, root } = await renderComponent(
<MarkdownRenderActionContext
value={{
ensureAndRenderTimeStamp: () => false,
isAudio: () => false,
onImageContextMenu,
transformUrl: (url) => (url ? new URL(url, "https://example.com/post").href : url),
}}
>
<MarkdownBlockImage src="./image.png" width={700} height={400} />
</MarkdownRenderActionContext>,
))
const image = container?.querySelector('[data-testid="media"]')
const event = new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: 12,
clientY: 34,
})
await act(async () => {
image?.dispatchEvent(event)
})
expect(onImageContextMenu).toHaveBeenCalledWith(
expect.objectContaining({ clientX: 12, clientY: 34 }),
"https://example.com/image.png",
)
})
})

View File

@ -17,8 +17,15 @@ export const MarkdownBlockImage = (
) => {
const size = useWrappedElementSize()
const { transformUrl } = use(MarkdownRenderActionContext)
const { onImageContextMenu, transformUrl } = use(MarkdownRenderActionContext)
const src = transformUrl(props.src)
const handleContextMenu = (event: React.MouseEvent<HTMLImageElement>) => {
props.onContextMenu?.(event)
if (src) {
void onImageContextMenu?.(event, src)
}
}
const media = useContextSelector(MarkdownImageRecordContext, (record) =>
props.src ? record[props.src] : null,
@ -33,6 +40,7 @@ export const MarkdownBlockImage = (
height={media?.height || props.height}
width={media?.width || props.width}
blurhash={media?.blurhash}
onContextMenu={handleContextMenu}
mediaContainerClassName={cn(
"rounded",
size.w < Number.parseInt(props.width as string) && "w-full",

View File

@ -13,8 +13,16 @@ export const MarkdownInlineImage = (
}
},
) => {
const { transformUrl } = use(MarkdownRenderActionContext)
const { onImageContextMenu, transformUrl } = use(MarkdownRenderActionContext)
const populatedUrl = transformUrl(props.src)
const handleContextMenu = (event: React.MouseEvent<HTMLImageElement>) => {
props.onContextMenu?.(event)
if (populatedUrl) {
void onImageContextMenu?.(event, populatedUrl)
}
}
const media = useContextSelector(MarkdownImageRecordContext, (record) =>
props.src ? record[props.src] : null,
)
@ -28,6 +36,7 @@ export const MarkdownInlineImage = (
height={media?.height || props.height}
width={media?.width || props.width}
blurhash={media?.blurhash}
onContextMenu={handleContextMenu}
mediaContainerClassName={cn("inline max-w-full rounded-md")}
popper
showFallback

View File

@ -1,3 +1,5 @@
import type { MouseEvent } from "react"
export type MarkdownImage = {
url: string
width?: number | undefined
@ -10,4 +12,5 @@ export interface MarkdownRenderActions {
transformUrl: (url?: string) => string | undefined
isAudio: (url?: string) => boolean
ensureAndRenderTimeStamp: (children: string) => React.ReactNode
onImageContextMenu?: (event: MouseEvent, imageUrl: string) => void | Promise<void>
}

View File

@ -0,0 +1,54 @@
import { SimpleIconsEagle } from "@follow/components/ui/platform-icon/icons.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { useEventCallback } from "usehooks-ts"
import { MenuItemText, useShowContextMenu } from "~/atoms/context-menu"
import { useIntegrationSettingKey } from "~/atoms/settings/integration"
import { ipcServices } from "~/lib/client"
import type { MarkdownRenderActions } from "../../../components/ui/markdown/types"
export const useImageContextMenu = (
entryUrl?: Nullable<string>,
): NonNullable<MarkdownRenderActions["onImageContextMenu"]> => {
const { t } = useTranslation()
const showContextMenu = useShowContextMenu()
const enableEagle = useIntegrationSettingKey("enableEagle")
return useEventCallback(async (event, imageUrl) => {
if (!IN_ELECTRON || !enableEagle || !ipcServices?.integration?.saveToEagle) {
return
}
event.preventDefault()
event.stopPropagation()
await showContextMenu(
[
new MenuItemText({
label: t("entry_actions.save_image_to_eagle"),
icon: <SimpleIconsEagle />,
click: async () => {
const response = await ipcServices?.integration.saveToEagle({
url: entryUrl || imageUrl,
mediaUrls: [imageUrl],
})
if (response?.status === "success") {
toast.success(t("entry_actions.saved_to_eagle"), {
duration: 3000,
})
} else {
toast.error(t("entry_actions.failed_to_save_to_eagle"), {
duration: 3000,
})
}
},
}),
],
event,
)
})
}

View File

@ -15,6 +15,7 @@ import type { MarkdownImage, MarkdownRenderActions } from "~/components/ui/markd
import { TimeStamp } from "./components/TimeStamp"
import { EntryInfoContext } from "./context"
import { useImageContextMenu } from "./hooks/useImageContextMenu"
import type { EntryContentRendererProps } from "./types"
export function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements = "div">({
@ -46,6 +47,7 @@ export function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements
})
const images: Record<string, MarkdownImage> = useMemo(() => entry?.images ?? {}, [entry])
const onImageContextMenu = useImageContextMenu(entry?.url)
const actions: MarkdownRenderActions = useMemo(() => {
return {
isAudio() {
@ -62,8 +64,9 @@ export function EntryContentHTMLRenderer<AS extends keyof JSX.IntrinsicElements
return url
},
ensureAndRenderTimeStamp,
onImageContextMenu,
}
}, [entry, feedId, view])
}, [entry, feedId, onImageContextMenu, view])
return (
// eslint-disable-next-line @eslint-react/no-context-provider
<MarkdownImageRecordContext.Provider value={images}>

View File

@ -14,6 +14,7 @@ import type { MarkdownImage, MarkdownRenderActions } from "~/components/ui/markd
import { TimeStamp } from "./components/TimeStamp"
import { EntryInfoContext } from "./context"
import { useImageContextMenu } from "./hooks/useImageContextMenu"
import type { EntryContentRendererProps } from "./types"
type MarkdownProps = Omit<ComponentProps<typeof Markdown>, "children">
@ -47,6 +48,7 @@ export function EntryContentMarkdownRenderer({
})
const images: Record<string, MarkdownImage> = useMemo(() => entry?.images ?? {}, [entry])
const onImageContextMenu = useImageContextMenu(entry?.url)
const actions: MarkdownRenderActions = useMemo(() => {
return {
isAudio() {
@ -63,8 +65,9 @@ export function EntryContentMarkdownRenderer({
return url
},
ensureAndRenderTimeStamp,
onImageContextMenu,
}
}, [entry, feedId, view])
}, [entry, feedId, onImageContextMenu, view])
return (
// eslint-disable-next-line @eslint-react/no-context-provider

View File

@ -12,12 +12,14 @@ import { jotaiStore } from "~/lib/jotai"
import { EntryContentHTMLRenderer } from "./html"
import { EntryContentMarkdownRenderer } from "./markdown"
const { htmlMock, markdownMock, useEntryMock, getFeedByIdMock } = vi.hoisted(() => ({
htmlMock: vi.fn(() => null),
markdownMock: vi.fn(() => null),
useEntryMock: vi.fn(),
getFeedByIdMock: vi.fn(),
}))
const { htmlMock, imageContextMenuHookMock, markdownMock, useEntryMock, getFeedByIdMock } =
vi.hoisted(() => ({
htmlMock: vi.fn(() => null),
imageContextMenuHookMock: vi.fn(() => {}),
markdownMock: vi.fn(() => null),
useEntryMock: vi.fn(),
getFeedByIdMock: vi.fn(),
}))
vi.mock("@follow/store/entry/hooks", () => ({
useEntry: useEntryMock,
@ -35,6 +37,10 @@ vi.mock("~/components/ui/markdown/Markdown", () => ({
Markdown: markdownMock,
}))
vi.mock("./hooks/useImageContextMenu", () => ({
useImageContextMenu: imageContextMenuHookMock,
}))
const rule = {
id: "rule-1",
enabled: true,

View File

@ -138,6 +138,7 @@
"entry_actions.no_bittorrent_urls_found": "No BitTorrent URLs found in this entry.",
"entry_actions.open_in_browser": "Open in {{which}}",
"entry_actions.recent_reader": "Recent Reader:",
"entry_actions.save_image_to_eagle": "Save Image to Eagle",
"entry_actions.save_media_to_eagle": "Save Media to Eagle",
"entry_actions.save_to_cubox": "Save to Cubox",
"entry_actions.save_to_instapaper": "Save to Instapaper",

View File

@ -138,6 +138,7 @@
"entry_actions.no_bittorrent_urls_found": "Aucune URL BitTorrent trouvée dans cette entrée.",
"entry_actions.open_in_browser": "Ouvrir dans {{which}}",
"entry_actions.recent_reader": "Lecteur récent :",
"entry_actions.save_image_to_eagle": "Enregistrer l'image dans Eagle",
"entry_actions.save_media_to_eagle": "Enregistrer le média dans Eagle",
"entry_actions.save_to_cubox": "Enregistrer dans Cubox",
"entry_actions.save_to_instapaper": "Enregistrer dans Instapaper",

View File

@ -138,6 +138,7 @@
"entry_actions.no_bittorrent_urls_found": "このエントリには BitTorrent URL が見つかりませんでした。",
"entry_actions.open_in_browser": "{{which}} で開く",
"entry_actions.recent_reader": "最近の購読者:",
"entry_actions.save_image_to_eagle": "画像を Eagle に保存",
"entry_actions.save_media_to_eagle": "メディアを Eagle に保存",
"entry_actions.save_to_cubox": "Cubox に保存",
"entry_actions.save_to_instapaper": "Instapaper に保存",

View File

@ -138,6 +138,7 @@
"entry_actions.no_bittorrent_urls_found": "此条目中未找到 BitTorrent 链接",
"entry_actions.open_in_browser": "在{{which}}打开",
"entry_actions.recent_reader": "最近阅读者:",
"entry_actions.save_image_to_eagle": "保存图片到 Eagle",
"entry_actions.save_media_to_eagle": "保存到 Eagle",
"entry_actions.save_to_cubox": "保存到 Cubox",
"entry_actions.save_to_instapaper": "保存到 Instapaper",

View File

@ -138,6 +138,7 @@
"entry_actions.no_bittorrent_urls_found": "此條目中未找到 BitTorrent 連結。",
"entry_actions.open_in_browser": "在{{which}}中打開",
"entry_actions.recent_reader": "最近閲讀者:",
"entry_actions.save_image_to_eagle": "儲存圖片至 Eagle",
"entry_actions.save_media_to_eagle": "儲存媒體至 Eagle",
"entry_actions.save_to_cubox": "儲存到 Cubox",
"entry_actions.save_to_instapaper": "儲存到 Instapaper",