From 13cc7193777087ecaf90e390ef13128175ac60e1 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Tue, 12 May 2026 17:14:30 +0800 Subject: [PATCH] fix(desktop): add Eagle image context menu --- .../ui/markdown/renderers/BlockImage.test.tsx | 105 ++++++++++++++++++ .../ui/markdown/renderers/BlockImage.tsx | 10 +- .../ui/markdown/renderers/InlineImage.tsx | 11 +- .../src/components/ui/markdown/types.ts | 3 + .../renderer/hooks/useImageContextMenu.tsx | 54 +++++++++ .../renderer/src/modules/renderer/html.tsx | 5 +- .../src/modules/renderer/markdown.tsx | 5 +- .../src/modules/renderer/spotlight.test.tsx | 18 ++- locales/app/en.json | 1 + locales/app/fr-FR.json | 1 + locales/app/ja.json | 1 + locales/app/zh-CN.json | 1 + locales/app/zh-TW.json | 1 + 13 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.test.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx diff --git a/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.test.tsx b/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.test.tsx new file mode 100644 index 000000000..ae1c4bd32 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.test.tsx @@ -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 & Record) => ( + + ), + ), + 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( + false, + isAudio: () => false, + onImageContextMenu, + transformUrl: (url) => (url ? new URL(url, "https://example.com/post").href : url), + }} + > + + , + )) + + 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", + ) + }) +}) diff --git a/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.tsx b/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.tsx index 7962224bc..afe96a83a 100644 --- a/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/BlockImage.tsx @@ -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) => { + 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", diff --git a/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/InlineImage.tsx b/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/InlineImage.tsx index 9909ba5b0..84b4eb48b 100644 --- a/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/InlineImage.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/markdown/renderers/InlineImage.tsx @@ -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) => { + 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 diff --git a/apps/desktop/layer/renderer/src/components/ui/markdown/types.ts b/apps/desktop/layer/renderer/src/components/ui/markdown/types.ts index b13de96c7..485e915c2 100644 --- a/apps/desktop/layer/renderer/src/components/ui/markdown/types.ts +++ b/apps/desktop/layer/renderer/src/components/ui/markdown/types.ts @@ -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 } diff --git a/apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx b/apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx new file mode 100644 index 000000000..94d2e0ee2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/renderer/hooks/useImageContextMenu.tsx @@ -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, +): NonNullable => { + 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: , + 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, + ) + }) +} diff --git a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx index b5c6ab0c3..4c18ca725 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/html.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/html.tsx @@ -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({ @@ -46,6 +47,7 @@ export function EntryContentHTMLRenderer = useMemo(() => entry?.images ?? {}, [entry]) + const onImageContextMenu = useImageContextMenu(entry?.url) const actions: MarkdownRenderActions = useMemo(() => { return { isAudio() { @@ -62,8 +64,9 @@ export function EntryContentHTMLRenderer diff --git a/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx b/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx index e65f657ef..678371cb0 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/markdown.tsx @@ -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, "children"> @@ -47,6 +48,7 @@ export function EntryContentMarkdownRenderer({ }) const images: Record = 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 diff --git a/apps/desktop/layer/renderer/src/modules/renderer/spotlight.test.tsx b/apps/desktop/layer/renderer/src/modules/renderer/spotlight.test.tsx index fabf5adfc..6357a5dbe 100644 --- a/apps/desktop/layer/renderer/src/modules/renderer/spotlight.test.tsx +++ b/apps/desktop/layer/renderer/src/modules/renderer/spotlight.test.tsx @@ -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, diff --git a/locales/app/en.json b/locales/app/en.json index ddc439efc..bb5c99945 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -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", diff --git a/locales/app/fr-FR.json b/locales/app/fr-FR.json index c98bab0c4..55886e631 100644 --- a/locales/app/fr-FR.json +++ b/locales/app/fr-FR.json @@ -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", diff --git a/locales/app/ja.json b/locales/app/ja.json index adc064008..b508257ab 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -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 に保存", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index d43df8a17..c3a2e88fd 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -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", diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index a4e065b31..55227cf56 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -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",