feat(desktop): implement popover for sharing entries with social media options

- Added a new Popover component to display sharing options for entries.
- Created SharePanel component to handle different sharing actions, including native sharing and copying links.
- Integrated mouse position tracking to position the popover correctly.
- Updated entry action commands to utilize the new sharing functionality.
- Added new icons for social media platforms and updated localization files for share-related texts.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-06-04 18:56:40 +08:00
parent 18b22b203f
commit 397365c508
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
22 changed files with 466 additions and 33 deletions

View File

@ -0,0 +1,43 @@
import type { PopoverContentProps } from "@radix-ui/react-popover"
import { atom } from "jotai"
import type { ReactNode } from "react"
import { createAtomHooks, jotaiStore } from "~/lib/jotai"
// Atom
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
/** Custom z-index for popover */
zIndex?: number
/** Whether the popover should close when clicked outside */
modal?: boolean
}
type PopoverState =
| { open: false }
| {
open: true
position: { x: number; y: number }
content: ReactNode
props?: PopoverProps
// Just for abort callback
abortController: AbortController
}
export const [popoverAtom, usePopoverState, usePopoverValue, useSetPopover] = createAtomHooks(
atom<PopoverState>({ open: false }),
)
export const showPopover = (
mouseXY: { x: number; y: number },
element: ReactNode,
props?: PopoverProps,
) => {
jotaiStore.set(popoverAtom, {
open: true,
position: mouseXY,
content: element,
props,
abortController: new AbortController(),
})
}

View File

@ -0,0 +1,264 @@
import { IN_ELECTRON } from "@follow/shared/constants"
import { cn } from "@follow/utils/utils"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { ipcServices } from "~/lib/client"
import { getEntry } from "~/store/entry"
interface SharePanelProps {
entryId: string
}
interface ShareOption {
id: string
label: string
icon: string
action: () => Promise<void> | void
color?: string
bgColor?: string
}
interface SocialShareOption {
id: string
label: string
icon: string
url: string
color: string
bgColor: string
}
const socialOptions: SocialShareOption[] = [
{
id: "twitter",
label: "X",
icon: tw`i-mgc-social-x-cute-re`,
url: "https://x.com/intent/tweet?text={text}&url={url}",
color: "text-white",
bgColor: "bg-black",
},
{
id: "facebook",
label: "Facebook",
icon: tw`i-mgc-facebook-cute-re`,
url: "https://www.facebook.com/sharer/sharer.php?u={url}",
color: "text-white",
bgColor: "bg-[#1877F2]",
},
{
id: "telegram",
label: "Telegram",
icon: tw`i-mgc-telegram-cute-re`,
url: "https://t.me/share/url?url={url}&text={text}",
color: "text-white",
bgColor: "bg-[#0088CC]",
},
{
id: "weibo",
label: "微博",
icon: tw`i-mgc-weibo-cute-re`,
url: "https://service.weibo.com/share/share.php?url={url}&title={text}",
color: "text-white",
bgColor: "bg-[#E6162D]",
},
]
export const SharePanel = ({ entryId }: SharePanelProps) => {
const { t } = useTranslation()
const generateShareContent = useCallback(
(entry: ReturnType<typeof getEntry>) => {
if (!entry) return null
const { title, description } = entry.entries
const shareUrl = globalThis.location.href
// Limit text to 50 characters with ellipsis
const truncateText = (text: string, maxLength = 50) => {
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text
}
const shareTitle = `${title || t("share.default_title")} - Follow`
const baseText = description || title || t("share.default_description")
const truncatedText = truncateText(baseText)
const shareText = `${truncatedText} | ${t("share.discover_more")}`
return {
title: shareTitle,
text: shareText,
url: shareUrl,
}
},
[t],
)
const handleNativeShare = useCallback(async () => {
const entry = getEntry(entryId)
const shareContent = generateShareContent(entry)
if (!shareContent) return
try {
if (IN_ELECTRON) {
// Use Electron's share menu
await ipcServices?.menu.showShareMenu(shareContent.url)
} else if (navigator.share) {
// Use Web Share API
await navigator.share({
title: shareContent.title,
text: shareContent.text,
url: shareContent.url,
})
} else {
// Fallback to copying link
await navigator.clipboard.writeText(shareContent.url)
toast.success(t("share.link_copied"))
}
} catch {
// If sharing fails, copy link as fallback
try {
await navigator.clipboard.writeText(shareContent.url)
toast.success(t("share.link_copied"))
} catch {
toast.error(t("share.copy_failed"))
}
}
}, [entryId, generateShareContent, t])
const handleCopyLink = useCallback(async () => {
const shareUrl = globalThis.location.href
try {
await navigator.clipboard.writeText(shareUrl)
toast.success(t("share.link_copied"))
} catch {
toast.error(t("share.copy_failed"))
}
}, [t])
const handleSocialShare = useCallback(
(shareUrlTemplate: string) => {
const entry = getEntry(entryId)
const shareContent = generateShareContent(entry)
if (!shareContent) return
const encodedUrl = encodeURIComponent(shareContent.url)
const shareTitle = encodeURIComponent(shareContent.title)
const shareText = encodeURIComponent(shareContent.text)
const finalUrl = shareUrlTemplate
.replace("{url}", encodedUrl)
.replace("{title}", shareTitle)
.replace("{text}", shareText)
window.open(finalUrl, "_blank", "width=600,height=400")
},
[entryId, generateShareContent],
)
const actionOptions: ShareOption[] = [
...(IN_ELECTRON || (typeof navigator !== "undefined" && "share" in navigator)
? [
{
id: "native-share",
label: t("share.system_share"),
icon: "i-mgc-share-forward-cute-re",
action: handleNativeShare,
color: "text-blue-500",
},
]
: []),
{
id: "copy-link",
label: t("share.copy_link"),
icon: "i-mgc-link-cute-re",
action: handleCopyLink,
},
]
return (
<div className="pointer-events-auto max-w-[400px] px-2">
<div className="mb-4 flex flex-col text-center">
<h3 className="text-text mb-2 mt-1 font-semibold">{t("share.title")}</h3>
{(() => {
const entry = getEntry(entryId)
const title = entry?.entries?.title
return title ? (
<p className="text-text-secondary mt-1 min-w-0 text-wrap text-left text-sm font-medium">
{title}
</p>
) : null
})()}
</div>
<div className="mb-6">
<div className="mb-3">
<h4 className="text-text-secondary text-xs font-medium uppercase tracking-wide">
{t("share.social_media")}
</h4>
</div>
<div className="flex items-center gap-4">
{socialOptions.map((option) => (
<button
key={option.id}
type="button"
className="group flex flex-col items-center gap-2"
onClick={() => handleSocialShare(option.url)}
>
<div
className={cn(
"flex size-12 items-center justify-center rounded-full transition-all duration-200",
option.bgColor,
"group-hover:scale-110 group-active:scale-95",
"shadow-lg",
)}
>
<i className={cn(option.icon, "size-5", option.color)} />
</div>
<span className="text-text-secondary text-xs font-medium">{option.label}</span>
</button>
))}
</div>
</div>
<div>
<div className="mb-3">
<h4 className="text-text-secondary text-xs font-medium uppercase tracking-wide">
{t("share.actions")}
</h4>
</div>
<div className="flex flex-col gap-1">
{actionOptions.map((option) => (
<button
key={option.id}
type="button"
className={cn(
"cursor-button relative flex select-none items-center rounded-lg",
"text-sm outline-none transition-all duration-200",
"hover:bg-fill-secondary/80 active:bg-fill-secondary",
"group",
)}
onClick={() => option.action()}
>
<div className="flex items-center gap-2">
<div
className={cn(
"flex size-7 items-center justify-center rounded-full",
"bg-fill-tertiary/80 group-hover:bg-fill-tertiary",
"transition-colors duration-200",
)}
>
<i
className={cn(option.icon, "size-3.5", option.color || "text-text-secondary")}
/>
</div>
<span className="text-text text-xs font-medium">{option.label}</span>
</div>
</button>
))}
</div>
</div>
</div>
)
}

View File

@ -328,7 +328,7 @@ export const useEntryActions = ({
new EntryActionMenuItem({
id: COMMAND_ID.entry.share,
onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]),
hide: !entry.url || !("share" in navigator || IN_ELECTRON),
hide: !entry.url,
shortcut: shortcuts[COMMAND_ID.entry.share],
entryId,
}),

View File

@ -1,3 +1,4 @@
import { getMousePosition } from "@follow/components/hooks/useMouse.js"
import { FeedViewType, UserRole } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared/constants"
import { cn, resolveUrlWithBase } from "@follow/utils/utils"
@ -8,6 +9,7 @@ import { toast } from "sonner"
import { toggleShowAISummaryOnce } from "~/atoms/ai-summary"
import { toggleShowAITranslationOnce } from "~/atoms/ai-translation"
import { AudioPlayer, getAudioPlayerAtomValue } from "~/atoms/player"
import { showPopover } from "~/atoms/popover"
import { useIsInMASReview } from "~/atoms/server-configs"
import { useGeneralSettingKey } from "~/atoms/settings/general"
import {
@ -16,6 +18,7 @@ import {
useSourceContentModal,
} from "~/atoms/source-content"
import { useUserRole } from "~/atoms/user"
import { SharePanel } from "~/components/common/SharePanel"
import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
@ -283,19 +286,15 @@ export const useRegisterEntryCommands = () => {
toast.error("Failed to share: url is not available", { duration: 3000 })
return
}
if (!entry.entries.url) return
if (IN_ELECTRON) {
return ipcServices?.menu.showShareMenu(entry.entries.url)
} else {
const { title, description } = entry.entries
navigator.share({
title: title || undefined,
text: description || undefined,
url: entry.entries.url,
})
}
return
const xy = getMousePosition()
showPopover(
{
x: xy.x,
y: xy.y + 20,
},
<SharePanel entryId={entry.entries.id} />,
)
},
},
{

View File

@ -256,7 +256,7 @@ export const ContainerToc = memo(
className={cn(
"animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft flex flex-col items-end",
"scrollbar-none max-h-[calc(100vh-100px)] overflow-auto",
"@[700px]:-translate-x-12 @[800px]:-translate-x-16 @[900px]:translate-x-0 @[900px]:items-start",
"@[700px]:-translate-x-12 @[800px]:-translate-x-4 @[900px]:translate-x-0 @[900px]:items-start",
)}
/>

View File

@ -4,3 +4,5 @@ export { ExternalJumpInProvider as LazyExternalJumpInProvider } from "../externa
export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container"
export const LazyReloadPrompt = () => null
export const LazyPWAPrompt = () => null
export { PopoverProvider as LazyPopoverProvider } from "../popover-provider"

View File

@ -1,9 +0,0 @@
export { ContextMenuProvider as LazyContextMenuProvider } from "../context-menu-provider"
export { ExtensionExposeProvider as LazyExtensionExposeProvider } from "../extension-expose-provider"
export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container"
export { ModalStackProvider as LazyModalStackProvider } from "~/components/ui/modal"
const noop = () => null
export const LazyReloadPrompt = noop
export const LazyPWAPrompt = noop
export const LazyExternalJumpInProvider = noop

View File

@ -10,6 +10,11 @@ const LazyContextMenuProvider = lazy(() =>
default: res.ContextMenuProvider,
})),
)
const LazyPopoverProvider = lazy(() =>
import("./../popover-provider").then((res) => ({
default: res.PopoverProvider,
})),
)
const LazyExtensionExposeProvider = lazy(() =>
import("./../extension-expose-provider").then((res) => ({
@ -47,6 +52,7 @@ export {
LazyContextMenuProvider,
LazyExtensionExposeProvider,
LazyLottieRenderContainer,
LazyPopoverProvider,
LazyPWAPrompt,
LazyReloadPrompt,
}

View File

@ -0,0 +1,81 @@
// Import from the correct path
import { useSetGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js"
import { Spring } from "@follow/components/constants/spring.js"
import {
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
} from "@follow/components/ui/popover/index.jsx"
import { AnimatePresence, m } from "motion/react"
import { memo, useEffect, useRef } from "react"
import { usePopoverState } from "~/atoms/popover"
import { HotkeyScope } from "~/constants"
export const PopoverProvider: Component = ({ children }) => (
<>
{children}
<Handler />
</>
)
const Handler = memo(() => {
const ref = useRef<HTMLButtonElement>(null)
const [popoverState, setPopoverState] = usePopoverState()
const setGlobalFocusableScope = useSetGlobalFocusableScope()
useEffect(() => {
if (!popoverState.open) return
const triggerElement = ref.current
if (!triggerElement) return
triggerElement.dispatchEvent(
new MouseEvent("click", {
bubbles: true,
cancelable: true,
}),
)
}, [popoverState])
return (
<Popover
onOpenChange={(state) => {
if (state) {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
} else {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
setPopoverState({ open: false })
}
}}
>
<PopoverTrigger
ref={ref}
className="pointer-events-none"
style={
popoverState.open
? { position: "fixed", top: popoverState.position.y, left: popoverState.position.x }
: {}
}
/>
<PopoverContent asChild forceMount>
<AnimatePresence>
{popoverState.open && (
<m.div
className="bg-material-ultra-thick backdrop-blur-background mr-2 rounded-xl border p-2 shadow-2xl"
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
transition={Spring.presets.smooth}
>
<PopoverArrow className="fill-border" />
{popoverState.content}
</m.div>
)}
</AnimatePresence>
</PopoverContent>
</Popover>
)
})
Handler.displayName = "PopoverHandler"

View File

@ -24,6 +24,7 @@ import {
LazyExtensionExposeProvider,
LazyExternalJumpInProvider,
LazyLottieRenderContainer,
LazyPopoverProvider,
LazyPWAPrompt,
LazyReloadPrompt,
} from "./lazy/index"
@ -56,6 +57,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<Suspense>
<LazyExtensionExposeProvider />
<LazyContextMenuProvider />
<LazyPopoverProvider />
<LazyLottieRenderContainer />
<LazyExternalJumpInProvider />
<LazyReloadPrompt />

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M11.08 2.045c-1.874.165-3.723.904-5.28 2.109-.437.339-1.307 1.209-1.646 1.646-1.8 2.326-2.505 5.195-1.976 8.046.29 1.566.959 3.04 1.976 4.354.339.437 1.209 1.307 1.646 1.646 2.441 1.889 5.453 2.566 8.44 1.895 2.487-.559 4.752-2.144 6.145-4.301.806-1.247 1.283-2.527 1.521-4.08.098-.641.098-2.079 0-2.72-.285-1.858-.936-3.388-2.06-4.84-.339-.437-1.209-1.307-1.646-1.646-2.067-1.599-4.554-2.336-7.12-2.109m1.752 1.997a8.182 8.182 0 0 1 4.208 1.747c.354.286 1.027.972 1.286 1.311A8.123 8.123 0 0 1 20 12a8.1 8.1 0 0 1-1.789 5.04c-.286.354-.972 1.027-1.311 1.286a8.467 8.467 0 0 1-2.4 1.269c-.479.156-1.203.325-1.398.325H13V14h.553c.696 0 .893-.052 1.144-.303.183-.183.303-.46.303-.697 0-.237-.12-.514-.303-.697-.251-.251-.448-.303-1.144-.303H13l.002-1.09c.002-1.172.015-1.267.215-1.529a1.18 1.18 0 0 1 .291-.248c.17-.099.234-.111.729-.134.615-.028.772-.082 1.002-.344.18-.205.241-.37.241-.655 0-.285-.061-.45-.241-.655-.254-.29-.375-.325-1.099-.324-.541.002-.69.016-.962.093-.958.27-1.756 1.057-2.054 2.025-.094.306-.099.379-.115 1.591L10.993 12h-.549c-.691 0-.89.053-1.138.3a.96.96 0 0 0 0 1.4c.248.248.447.3 1.141.3H11v5.92h-.102c-.426 0-1.649-.371-2.369-.72a7.375 7.375 0 0 1-2.083-1.459 7.632 7.632 0 0 1-1.645-2.267c-1.321-2.735-.987-5.939.873-8.374.259-.339.932-1.025 1.286-1.311a8.254 8.254 0 0 1 4.16-1.745 10.09 10.09 0 0 1 1.712-.002" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18.221 4.525c-.587.085-1.448.357-2.636.832-1.051.421-8.787 3.649-9.561 3.99-2.222.981-3.27 1.694-3.691 2.513-.222.431-.267.665-.244 1.244.023.569.119.869.403 1.267.218.305.463.532.84.778.365.238 1.191.633 1.488.711.347.092.806.085 1.112-.017.719-.24 2.292-1.28 5.105-3.375 2.768-2.063 3.636-2.637 4.074-2.696.136-.018.168-.008.207.064.069.13-.057.368-.406.764-.311.353-.588.616-2.232 2.122-1.764 1.616-2.971 2.873-3.201 3.333-.22.44-.143.918.214 1.329.139.159.433.369 2.767 1.976 1.994 1.373 2.881 1.888 3.58 2.079.981.269 1.83.06 2.502-.615.251-.252.365-.406.512-.693.385-.753.571-1.506 1.023-4.151.653-3.824 1.085-6.441 1.161-7.027.223-1.735.12-2.69-.369-3.422-.541-.808-1.502-1.173-2.648-1.006" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18.34 4.467c-.595.075-1.434.325-2.518.751-.735.289-7.532 3.116-9.11 3.789-1.28.546-2.64 1.218-3.152 1.558-.441.293-1.055.886-1.215 1.175-.228.408-.271.571-.293 1.1-.035.868.127 1.3.706 1.884.784.789 1.926 1.262 5.742 2.377.506.148.99.304 1.075.347.085.043.886.584 1.78 1.203 3.665 2.535 4.219 2.839 5.345 2.93.999.081 1.908-.483 2.419-1.501.411-.819.536-1.362 1.177-5.14.945-5.561 1.043-6.204 1.089-7.131.045-.881-.092-1.635-.389-2.156-.5-.876-1.516-1.329-2.656-1.186m.86 2.12c.219.245.254.802.119 1.895-.115.931-.178 1.323-.762 4.778-.74 4.372-.878 5.074-1.12 5.702-.156.403-.366.638-.57.638-.355 0-.846-.231-1.814-.854a154.94 154.94 0 0 1-3.367-2.297l-.135-.1 2.141-2.144c1.177-1.18 2.173-2.208 2.213-2.285.106-.204.1-.649-.013-.86a1.113 1.113 0 0 0-.501-.478c-.206-.086-.612-.079-.811.015-.108.051-.919.835-2.5 2.419-1.287 1.289-2.361 2.344-2.387 2.344-.085 0-2.935-.868-3.553-1.082-1.712-.593-2.296-1.005-2.098-1.483.151-.364 1.206-.98 3.04-1.775.876-.379 8.136-3.404 8.967-3.735 1.645-.657 2.285-.851 2.745-.834.273.01.303.02.406.136" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M16.643 3.069C16.291 3.193 16 3.614 16 4c0 .396.291.808.66.934.107.036.31.066.451.066.341 0 .98.133 1.358.283a4.013 4.013 0 0 1 2.248 2.248c.15.378.283 1.017.283 1.358 0 .348.096.609.299.812.64.64 1.701.172 1.701-.75 0-.107-.019-.369-.042-.583a5.986 5.986 0 0 0-5.326-5.326c-.516-.056-.775-.049-.989.027m-6.176 1.972c-.819.129-1.58.451-2.64 1.115-1.573.985-3.025 2.34-4.058 3.789-1.444 2.024-2.048 4.188-1.629 5.84.382 1.503 1.606 2.89 3.381 3.83 3.475 1.839 8.475 1.838 11.959-.001 1.705-.901 2.924-2.241 3.338-3.671a4.603 4.603 0 0 0 .167-1.458c-.096-1.255-.765-2.226-2.067-2.998l-.383-.227.057-.18c.031-.099.065-.423.076-.72.023-.624-.03-.928-.233-1.33-.665-1.318-2.113-1.794-4.041-1.327a12.27 12.27 0 0 1-.417.097c-.009 0-.017-.064-.017-.142 0-.255-.124-.711-.284-1.046a2.79 2.79 0 0 0-1.815-1.501c-.327-.088-1.051-.124-1.394-.07m.921 2.019c.56.167.747.84.455 1.64-.072.198-.15.441-.173.541-.137.59.33 1.171.946 1.175.211.002.303-.026.684-.202 1.055-.488 1.697-.669 2.38-.672.391-.002.488.011.649.085.466.216.504.813.092 1.473-.23.369-.288.597-.224.88.055.247.229.508.414.621.071.044.255.127.409.185.687.26 1.274.618 1.581.963.463.52.519 1.204.163 1.988-.724 1.597-2.985 2.831-5.844 3.19-.552.07-2.288.07-2.84 0-3.2-.402-5.618-1.896-6.025-3.722-.121-.545-.009-1.388.291-2.184.265-.708.812-1.638 1.398-2.381.425-.539 1.61-1.693 2.173-2.115 1.634-1.227 2.723-1.687 3.471-1.465" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -136,16 +136,14 @@
"entry_actions.save_to_obsidian": "Save to Obsidian",
"entry_actions.save_to_outline": "Save to Outline",
"entry_actions.save_to_readeck": "Save to Readeck",
"entry_actions.save_to_readwise": "Save to Readwise",
"entry_actions.save_to_zotero": "Save to Zotero",
"entry_actions.save_to_readwise": "Saved to Readwise.",
"entry_actions.save_to_zotero": "Saved to Zotero",
"entry_actions.saved_to_cubox": "Saved to Cubox",
"entry_actions.saved_to_eagle": "Saved to Eagle.",
"entry_actions.saved_to_instapaper": "Saved to Instapaper.",
"entry_actions.saved_to_obsidian": "Saved to Obsidian",
"entry_actions.saved_to_outline": "Saved to Outline.",
"entry_actions.saved_to_readeck": "Saved to Readeck.",
"entry_actions.saved_to_readwise": "Saved to Readwise.",
"entry_actions.saved_to_zotero": "Saved to Zotero",
"entry_actions.share": "Share",
"entry_actions.star": "Star / UnStar",
"entry_actions.starred": "Starred.",
@ -337,6 +335,16 @@
"search.placeholder": "Search...",
"search.result_count_local_mode": "(Local mode)",
"search.tooltip.local_search": "This search covers locally available data. Try a Refetch to include the latest data.",
"share.actions": "Actions",
"share.copy_failed": "Failed to copy link",
"share.copy_link": "Copy Link",
"share.default_description": "Check out this entry",
"share.default_title": "Entry Share",
"share.discover_more": "Discover more on Follow",
"share.link_copied": "Link copied to clipboard",
"share.social_media": "Social Media",
"share.system_share": "System Share",
"share.title": "Share Entry",
"shortcuts.guide.title": "Shortcuts Guideline",
"sidebar.add_more_feeds": "Add more feeds",
"sidebar.category_remove_dialog.cancel": "Cancel",

View File

@ -120,7 +120,6 @@
"entry_actions.saved_to_obsidian": "Obsidian に保存されました。",
"entry_actions.saved_to_outline": "Outline に保存されました。",
"entry_actions.saved_to_readeck": "Readeck に保存されました。",
"entry_actions.saved_to_readwise": "Readwise に保存されました。",
"entry_actions.share": "共有",
"entry_actions.star": "スター",
"entry_actions.starred": "スターに追加されました。",

View File

@ -143,8 +143,6 @@
"entry_actions.saved_to_obsidian": "已保存到 Obsidian。",
"entry_actions.saved_to_outline": "已保存到 Outline。",
"entry_actions.saved_to_readeck": "已保存到 Readeck。",
"entry_actions.saved_to_readwise": "已保存到 Readwise。",
"entry_actions.saved_to_zotero": "已保存到 Zotero。",
"entry_actions.share": "分享",
"entry_actions.star": "收藏",
"entry_actions.starred": "已收藏",
@ -336,6 +334,16 @@
"search.placeholder": "搜索...",
"search.result_count_local_mode": "(本地模式)",
"search.tooltip.local_search": "当前搜索仅包含本地可用数据,尝试重新搜索得到更多结果。",
"share.actions": "操作",
"share.copy_failed": "复制链接失败",
"share.copy_link": "复制链接",
"share.default_description": "查看这个精彩内容",
"share.default_title": "内容分享",
"share.discover_more": "在 Follow 上发现更多精彩内容",
"share.link_copied": "链接已复制到剪贴板",
"share.social_media": "社交媒体",
"share.system_share": "系统分享",
"share.title": "分享内容",
"shortcuts.guide.title": "快捷键指南",
"sidebar.add_more_feeds": "添加订阅源",
"sidebar.category_remove_dialog.cancel": "取消",

View File

@ -141,8 +141,6 @@
"entry_actions.saved_to_obsidian": "已儲存到 Obsidian。",
"entry_actions.saved_to_outline": "已儲存到 Outline。",
"entry_actions.saved_to_readeck": "已儲存到 Readeck。",
"entry_actions.saved_to_readwise": "已儲存到 Readwise。",
"entry_actions.saved_to_zotero": "已儲存到 Zotero。",
"entry_actions.share": "分享",
"entry_actions.star": "收藏",
"entry_actions.starred": "已收藏",

View File

@ -0,0 +1,6 @@
import { atom } from "jotai"
export const mouseAtom = atom({
x: 0,
y: 0,
})

View File

@ -0,0 +1,10 @@
import { jotaiStore } from "@follow/utils"
import { useAtomValue } from "jotai"
import { mouseAtom } from "../atoms/mouse"
export const useMousePosition = () => {
return useAtomValue(mouseAtom)
}
export const getMousePosition = () => jotaiStore.get(mouseAtom)

View File

@ -4,6 +4,8 @@ import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect"
import { useStore } from "jotai"
import type { FC } from "react"
import { mouseAtom } from "../atoms/mouse"
export const EventProvider: FC = () => {
const store = useStore()
useIsomorphicLayoutEffect(() => {
@ -36,5 +38,13 @@ export const EventProvider: FC = () => {
}
}, [])
useIsomorphicLayoutEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
store.set(mouseAtom, { x: e.clientX, y: e.clientY })
}
window.addEventListener("mousemove", handleMouseMove)
return () => window.removeEventListener("mousemove", handleMouseMove)
}, [store])
return null
}

View File

@ -11,6 +11,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverClose = PopoverPrimitive.Close
const PopoverArrow = PopoverPrimitive.Arrow
const PopoverContent = ({
ref,
className,
@ -37,4 +39,4 @@ const PopoverContent = ({
)
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverClose, PopoverContent, PopoverTrigger }
export { Popover, PopoverArrow, PopoverClose, PopoverContent, PopoverTrigger }