diff --git a/package.json b/package.json index 35144c206..8579a8667 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "dotenv": "16.4.5", "electron-store": "8.2.0", "electron-updater": "^6.1.7", + "foxact": "0.2.35", "framer-motion": "11.2.9", "franc-min": "6.2.0", "fuse.js": "7.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ed7ae168..bb3663888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: electron-updater: specifier: ^6.1.7 version: 6.1.8 + foxact: + specifier: 0.2.35 + version: 0.2.35(react@18.3.1) framer-motion: specifier: 11.2.9 version: 11.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2404,6 +2407,9 @@ packages: peerDependencies: react: '>=16.8.0' + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3179,6 +3185,14 @@ packages: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} + foxact@0.2.35: + resolution: {integrity: sha512-5e2p34TcBETZTTMhmf4WuljS07lgtv3laimqJer9OGOqYgOpYHgKfR0Cie8Q2IxH1FFhVkm1l0+2W/XL8nBL7Q==} + peerDependencies: + react: '*' + peerDependenciesMeta: + react: + optional: true + framer-motion@11.2.9: resolution: {integrity: sha512-gfxNSkp4dC3vpy2hGNQK3K9bNOKwfasqOhrqvmZzYxCPSJ9Tpv/9JlCkeCMgFdKefgPr8+JiouGjVmaDzu750w==} peerDependencies: @@ -4798,6 +4812,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} @@ -7881,6 +7898,8 @@ snapshots: - '@types/react' - react-dom + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -8931,6 +8950,13 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 + foxact@0.2.35(react@18.3.1): + dependencies: + client-only: 0.0.1 + server-only: 0.0.1 + optionalDependencies: + react: 18.3.1 + framer-motion@11.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: tslib: 2.6.2 @@ -10750,6 +10776,8 @@ snapshots: type-fest: 0.13.1 optional: true + server-only@0.0.1: {} + set-blocking@2.0.0: {} setimmediate@1.0.5: {} diff --git a/src/renderer/src/components/common/ProviderComposer.tsx b/src/renderer/src/components/common/ProviderComposer.tsx new file mode 100644 index 000000000..e9929e852 --- /dev/null +++ b/src/renderer/src/components/common/ProviderComposer.tsx @@ -0,0 +1,12 @@ +"use client" + +import type { JSX } from "react" +import { cloneElement } from "react" + +export const ProviderComposer: Component<{ + contexts: JSX.Element[] +}> = ({ contexts, children }) => + contexts.reduceRight( + (kids: any, parent: any) => cloneElement(parent, { children: kids }), + children, + ) diff --git a/src/renderer/src/components/ui/auto-resize-height.tsx b/src/renderer/src/components/ui/auto-resize-height.tsx new file mode 100644 index 000000000..7b7d1f8a0 --- /dev/null +++ b/src/renderer/src/components/ui/auto-resize-height.tsx @@ -0,0 +1,57 @@ +import { cn } from "@renderer/lib/utils" +import type { Spring } from "framer-motion" +import { m } from "framer-motion" +import { useEffect, useRef, useState } from "react" + +const softSpringPreset: Spring = { + duration: 0.35, + type: "spring", + stiffness: 120, + damping: 20, +} + +interface AnimateChangeInHeightProps { + children: React.ReactNode + className?: string + duration?: number + + spring?: boolean +} + +export const AutoResizeHeight: React.FC = ({ + children, + className, + duration = 0.6, + spring = false, +}) => { + const containerRef = useRef(null) + const [height, setHeight] = useState("auto") + + useEffect(() => { + if (!containerRef.current) return + const resizeObserver = new ResizeObserver((entries) => { + // We only have one entry, so we can use entries[0]. + const observedHeight = entries[0].contentRect.height + setHeight(observedHeight) + }) + + resizeObserver.observe(containerRef.current) + + return () => { + // Cleanup the observer when the component is unmounted + resizeObserver.disconnect() + } + }, []) + + return ( + +
{children}
+
+ ) +} diff --git a/src/renderer/src/lib/jotai.ts b/src/renderer/src/lib/jotai.ts index 076cac059..db3c85142 100644 --- a/src/renderer/src/lib/jotai.ts +++ b/src/renderer/src/lib/jotai.ts @@ -1,5 +1,7 @@ -import type { PrimitiveAtom } from "jotai" +import type { Atom, PrimitiveAtom } from "jotai" import { createStore, useAtom, useAtomValue, useSetAtom } from "jotai" +import { selectAtom } from "jotai/utils" +import { useCallback } from "react" export const jotaiStore = createStore() @@ -21,3 +23,16 @@ export const createAtomHooks = (atom: PrimitiveAtom) => () => useSetAtom(atom), ...createAtomAccessor(atom), ] as const + +export const createAtomSelector = (atom: Atom) => { + const useHook = (selector: (a: T) => R, deps: any[] = []) => + useAtomValue( + selectAtom( + atom, + useCallback((a) => selector(a as T), deps), + ), + ) + + useHook.__atom = atom + return useHook +} diff --git a/src/renderer/src/modules/entry-content/atoms.tsx b/src/renderer/src/modules/entry-content/atoms.tsx new file mode 100644 index 000000000..05e0727aa --- /dev/null +++ b/src/renderer/src/modules/entry-content/atoms.tsx @@ -0,0 +1,14 @@ +/* eslint-disable unicorn/no-unreadable-array-destructuring */ + +import { createAtomHooks } from "@renderer/lib/jotai" +import { atom } from "jotai" + +export const [, , useEntryTitleMeta, , getEntryTitleMeta, setEntryTitleMeta] = + createAtomHooks( + atom( + null as Nullable<{ + title: string + description: string + }>, + ), + ) diff --git a/src/renderer/src/modules/entry-content/header.tsx b/src/renderer/src/modules/entry-content/header.tsx new file mode 100644 index 000000000..1b1f8344f --- /dev/null +++ b/src/renderer/src/modules/entry-content/header.tsx @@ -0,0 +1,70 @@ +import { ActionButton } from "@renderer/components/ui/button" +import { useEntryActions } from "@renderer/hooks" +import { cn } from "@renderer/lib/utils" +import { useEntry } from "@renderer/store/entry/hooks" +import { AnimatePresence, m } from "framer-motion" + +import { useEntryTitleMeta } from "./atoms" + +export function EntryHeader({ + view, + entryId, +}: { + view: number + entryId: string +}) { + const entry = useEntry(entryId) + + const { items } = useEntryActions({ + view, + entry, + }) + const entryTitleMeta = useEntryTitleMeta() + if (!entry?.entries.url) return null + + return ( +
+
+ + {entryTitleMeta && ( + + {entryTitleMeta.title} + + + {entryTitleMeta.description} + + + )} + +
+
+ {items + .filter((item) => !item.disabled) + .map((item) => ( + + ) : ( + + ) + } + onClick={item.onClick} + tooltip={item.name} + key={item.name} + /> + ))} +
+
+ ) +} diff --git a/src/renderer/src/modules/entry-content/index.tsx b/src/renderer/src/modules/entry-content/index.tsx index b8341cda3..fa12aa33b 100644 --- a/src/renderer/src/modules/entry-content/index.tsx +++ b/src/renderer/src/modules/entry-content/index.tsx @@ -1,7 +1,12 @@ import { Logo } from "@renderer/components/icons/logo" +import { AutoResizeHeight } from "@renderer/components/ui/auto-resize-height" import { useBizQuery } from "@renderer/hooks" import { parseHtml } from "@renderer/lib/parse-html" import type { ActiveEntryId } from "@renderer/models" +import { + useIsSoFWrappedElement, + WrappedElementProvider, +} from "@renderer/providers/wrapped-element-provider" import { Queries } from "@renderer/queries" import { useEntry, useFeedStore } from "@renderer/store" import { m } from "framer-motion" @@ -9,7 +14,8 @@ import { useEffect, useState } from "react" import { LoadingCircle } from "../../components/ui/loading" import { EntryTranslation } from "../entry-column/translation" -import { EntryShare } from "./share" +import { setEntryTitleMeta } from "./atoms" +import { EntryHeader } from "./header" export const EntryContent = ({ entry }: { entry: ActiveEntryId }) => { const activeList = useFeedStore((state) => state.activeList) @@ -17,7 +23,7 @@ export const EntryContent = ({ entry }: { entry: ActiveEntryId }) => { if (!entry) { return ( @@ -74,8 +80,8 @@ function EntryContentRender({ entryId }: { entryId: string }) { return ( <> - -
+ +
{entry.entries.publishedAt && - new Date(entry.entries.publishedAt).toUTCString()} + new Date(entry.entries.publishedAt).toLocaleString()}
-
- {(summary.isLoading || summary.data) && ( -
-
- - AI summary + + +
+ {(summary.isLoading || summary.data) && ( +
+
+ + AI summary +
+ + {summary.isLoading ? + SummaryLoadingSkeleton : + summary.data} +
-
- {summary.isLoading ? "Loading..." : summary.data} -
-
- )} - {content} -
+ )} + {content} +
+ {!content && (
{!error ? ( @@ -136,3 +150,29 @@ function EntryContentRender({ entryId }: { entryId: string }) { ) } + +const SummaryLoadingSkeleton = ( +
+ + + +
+) + +const TitleMetaHandler: Component<{ + entryId: string +}> = ({ entryId }) => { + const isAtTop = useIsSoFWrappedElement() + const { + entries: { title: entryTitle }, + feeds: { title: feedTitle }, + } = useEntry(entryId)! + + useEffect(() => { + if (!isAtTop && entryTitle && feedTitle) { setEntryTitleMeta({ title: entryTitle, description: feedTitle }) } + return () => { + setEntryTitleMeta(null) + } + }, [entryId, entryTitle, feedTitle, isAtTop]) + return null +} diff --git a/src/renderer/src/modules/entry-content/share.tsx b/src/renderer/src/modules/entry-content/share.tsx deleted file mode 100644 index c9ba84943..000000000 --- a/src/renderer/src/modules/entry-content/share.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { ActionButton } from "@renderer/components/ui/button" -import { - TooltipProvider, -} from "@renderer/components/ui/tooltip" -import { useEntryActions } from "@renderer/hooks" -import { useEntry } from "@renderer/store/entry/hooks" - -export function EntryShare({ - view, - entryId, -}: { - view: number - entryId: string -}) { - const entry = useEntry(entryId) - - const { items } = useEntryActions({ - view, - entry, - }) - if (!entry?.entries.url) return null - - return ( -
- - {items - .filter((item) => !item.disabled) - .map((item) => ( - - ) : - ( - - ) - } - onClick={item.onClick} - tooltip={item.name} - key={item.name} - /> - ))} - -
- ) -} diff --git a/src/renderer/src/pages/(main)/(context)/index.tsx b/src/renderer/src/pages/(main)/(context)/index.tsx index 04250ab14..33298a4e8 100644 --- a/src/renderer/src/pages/(main)/(context)/index.tsx +++ b/src/renderer/src/pages/(main)/(context)/index.tsx @@ -43,8 +43,7 @@ export function Component() { const inWideMode = activeList && views[activeList.view].wideMode return ( -
- +
{!inWideMode && ( -
+
)} diff --git a/src/renderer/src/pages/(main)/(context)/layout.tsx b/src/renderer/src/pages/(main)/(context)/layout.tsx index a0740c78e..0341d292e 100644 --- a/src/renderer/src/pages/(main)/(context)/layout.tsx +++ b/src/renderer/src/pages/(main)/(context)/layout.tsx @@ -8,7 +8,8 @@ export function Component() {
-
+ {/* NOTE: tabIndex for main element can get by `document.activeElement` */} +
diff --git a/src/renderer/src/providers/wrapped-element-provider.tsx b/src/renderer/src/providers/wrapped-element-provider.tsx new file mode 100644 index 000000000..6f21e4b1e --- /dev/null +++ b/src/renderer/src/providers/wrapped-element-provider.tsx @@ -0,0 +1,149 @@ +import { ProviderComposer } from "@renderer/components/common/ProviderComposer" +import { cn } from "@renderer/lib/utils" +import { createContextState } from "foxact/create-context-state" +import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect" +import { memo, useEffect, useRef } from "react" + +const [ + WrappedElementProviderInternal, + useWrappedElement, + useSetWrappedElement, +] = createContextState(undefined as any) + +const [ + ElementSizeProviderInternal, + useWrappedElementSize, + useSetWrappedElementSize, +] = createContextState({ + h: 0, + w: 0, +}) + +const [ + ElementPositionProviderInternal, + useWrappedElementPosition, + useSetElementPosition, +] = createContextState({ + x: 0, + y: 0, +}) + +const [ + IsEndOfElementProviderInternal, + useIsEoFWrappedElement, + useSetIsEOfElement, +] = createContextState(false) + +const [ + IsStartOfElementProviderInternal, + useIsSoFWrappedElement, + useSetIsSOfElement, +] = createContextState(false) + +const Providers = [ + , + , + , + , + , +] + +interface WrappedElementProviderProps { + boundingDetection?: boolean + as?: keyof React.JSX.IntrinsicElements +} + +export const WrappedElementProvider: Component = ({ + children, + className, + ...props +}) => ( + + + + {children} + + +) +const ArticleElementResizeObserver = () => { + const setSize = useSetWrappedElementSize() + const setPos = useSetElementPosition() + const $article = useWrappedElement() + useIsomorphicLayoutEffect(() => { + if (!$article) return + const { height, width, x, y } = $article.getBoundingClientRect() + setSize({ h: height, w: width }) + setPos({ x, y }) + + const observer = new ResizeObserver((entries) => { + const entry = entries[0] + const { height, width, x, y } = entry.contentRect + setSize({ h: height, w: width }) + setPos({ x, y }) + }) + observer.observe($article) + return () => { + observer.unobserve($article) + observer.disconnect() + } + }, [$article]) + + return null +} + +const Content: Component = memo( + ({ children, className, boundingDetection, as = "div" }) => { + const setElement = useSetWrappedElement() + + const As = as as any + return ( + + {boundingDetection && } + {children} + {boundingDetection && } + + ) + }, +) + +Content.displayName = "ArticleElementProviderContent" + +const BoundingDetection: Component<{ + bounding: "start" | "end" +}> = ({ bounding }) => { + const endSetter = useSetIsEOfElement() + const startSetter = useSetIsSOfElement() + const ref = useRef(null) + useEffect(() => { + if (!ref.current) return + const $el = ref.current + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0] + + if (bounding === "start") startSetter(entry.isIntersecting) + else endSetter(entry.isIntersecting) + }, + { + rootMargin: "0px 0px 0px 0px", + }, + ) + + observer.observe($el) + return () => { + observer.unobserve($el) + observer.disconnect() + } + }, []) + + return
+} + +export { + useIsEoFWrappedElement, + useIsSoFWrappedElement, + useSetWrappedElement, + useWrappedElement, + useWrappedElementPosition, + useWrappedElementSize, +}