fix(desktop): add Eagle image context menu
This commit is contained in:
parent
4be035d37e
commit
13cc719377
|
|
@ -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",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 に保存",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue