feat: add AutoResizeComponent and entry content header for metadata (#72)

* feat: add component

Signed-off-by: Innei <i@innei.in>

* feat: entry title meta

Signed-off-by: Innei <i@innei.in>

* fix: update anchor

Signed-off-by: Innei <i@innei.in>

* fix: ts error

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-06-18 18:47:58 +08:00 committed by GitHub
parent 302dc4c7cd
commit 7372034c69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 409 additions and 70 deletions

View File

@ -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",

View File

@ -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: {}

View File

@ -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,
)

View File

@ -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<AnimateChangeInHeightProps> = ({
children,
className,
duration = 0.6,
spring = false,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null)
const [height, setHeight] = useState<number | "auto">("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 (
<m.div
className={cn("overflow-hidden", className)}
style={{ height }}
initial={false}
animate={{ height }}
transition={spring ? softSpringPreset : { duration }}
>
<div ref={containerRef}>{children}</div>
</m.div>
)
}

View File

@ -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 = <T>(atom: PrimitiveAtom<T>) =>
() => useSetAtom(atom),
...createAtomAccessor(atom),
] as const
export const createAtomSelector = <T>(atom: Atom<T>) => {
const useHook = <R>(selector: (a: T) => R, deps: any[] = []) =>
useAtomValue(
selectAtom(
atom,
useCallback((a) => selector(a as T), deps),
),
)
useHook.__atom = atom
return useHook
}

View File

@ -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
}>,
),
)

View File

@ -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 (
<div
className={cn(
"flex h-[55px] min-w-0 items-center justify-between gap-3 overflow-hidden px-5 text-lg text-zinc-500",
entryTitleMeta && "border-b border-border",
)}
>
<div className="flex min-w-0 shrink">
<AnimatePresence>
{entryTitleMeta && (
<m.div
initial={{ opacity: 0.01, y: 30 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0.01, y: 30 }}
className="flex min-w-0 shrink items-end gap-2 truncate text-sm leading-tight text-theme-foreground"
>
<span className="min-w-0 shrink truncate font-bold">{entryTitleMeta.title}</span>
<i className="i-mingcute-line-line size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
<span className="shrink-0 truncate text-xs opacity-80">
{entryTitleMeta.description}
</span>
</m.div>
)}
</AnimatePresence>
</div>
<div className="flex items-center gap-3">
{items
.filter((item) => !item.disabled)
.map((item) => (
<ActionButton
icon={
item.icon ? (
<img className="size-4 grayscale" src={item.icon} />
) : (
<i className={item.className} />
)
}
onClick={item.onClick}
tooltip={item.name}
key={item.name}
/>
))}
</div>
</div>
)
}

View File

@ -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 (
<m.div
className="-mt-2 flex size-full flex-col items-center justify-center gap-1 text-lg font-medium text-zinc-400"
className="-mt-2 flex size-full min-w-0 flex-col items-center justify-center gap-1 text-lg font-medium text-zinc-400"
initial={{ opacity: 0.01, y: 300 }}
animate={{ opacity: 1, y: 0 }}
>
@ -74,8 +80,8 @@ function EntryContentRender({ entryId }: { entryId: string }) {
return (
<>
<EntryShare entryId={entry.entries.id} view={0} />
<div className="h-[calc(100%-3.5rem)] overflow-y-auto @container">
<EntryHeader entryId={entry.entries.id} view={0} />
<div className="h-[calc(100%-3.5rem)] min-w-0 overflow-y-auto @container">
<m.div
className="p-5"
initial={{ opacity: 0.01, y: 100 }}
@ -101,23 +107,31 @@ function EntryContentRender({ entryId }: { entryId: string }) {
</div>
<div className="text-[13px] text-zinc-500">
{entry.entries.publishedAt &&
new Date(entry.entries.publishedAt).toUTCString()}
new Date(entry.entries.publishedAt).toLocaleString()}
</div>
</a>
<div className="prose prose-zinc mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[15px] dark:prose-invert">
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800">
<i className="i-mingcute-bling-line align-middle" />
<span>AI summary</span>
<WrappedElementProvider boundingDetection>
<TitleMetaHandler entryId={entry.entries.id} />
<div className="prose prose-zinc mx-auto mb-32 mt-8 max-w-full cursor-auto select-text break-all text-[15px] dark:prose-invert">
{(summary.isLoading || summary.data) && (
<div className="my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center gap-2 font-medium text-zinc-800">
<i className="i-mingcute-bling-line align-middle" />
<span>AI summary</span>
</div>
<AutoResizeHeight
spring
className="text-sm leading-relaxed"
>
{summary.isLoading ?
SummaryLoadingSkeleton :
summary.data}
</AutoResizeHeight>
</div>
<div className="text-sm leading-relaxed">
{summary.isLoading ? "Loading..." : summary.data}
</div>
</div>
)}
{content}
</div>
)}
{content}
</div>
</WrappedElementProvider>
{!content && (
<div className="center mt-16">
{!error ? (
@ -136,3 +150,29 @@ function EntryContentRender({ entryId }: { entryId: string }) {
</>
)
}
const SummaryLoadingSkeleton = (
<div className="space-y-2">
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
<span className="block h-3 w-full animate-pulse rounded-xl bg-zinc-200 dark:bg-neutral-800" />
</div>
)
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
}

View File

@ -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 (
<div className="flex items-center justify-end gap-3 px-5 pt-2.5 text-lg text-zinc-500">
<TooltipProvider>
{items
.filter((item) => !item.disabled)
.map((item) => (
<ActionButton
icon={
item.icon ?
(
<img className="size-4 grayscale" src={item.icon} />
) :
(
<i className={item.className} />
)
}
onClick={item.onClick}
tooltip={item.name}
key={item.name}
/>
))}
</TooltipProvider>
</div>
)
}

View File

@ -43,8 +43,7 @@ export function Component() {
const inWideMode = activeList && views[activeList.view].wideMode
return (
<div ref={containerRef} className="flex grow">
<div ref={containerRef} className="flex min-w-0 grow">
<div
className={cn(
"h-full shrink-0 overflow-y-auto",
@ -65,7 +64,7 @@ export function Component() {
)}
<AnimatePresence>
{!inWideMode && (
<div className="flex-1">
<div className="min-w-0 flex-1">
<EntryContent entry={activeEntry} />
</div>
)}

View File

@ -8,7 +8,8 @@ export function Component() {
<div className="w-64 shrink-0 border-r">
<FeedColumn />
</div>
<main ref={setMainContainerElement} className="flex flex-1 bg-theme-background !outline-none" tabIndex={-1}>
{/* NOTE: tabIndex for main element can get by `document.activeElement` */}
<main ref={setMainContainerElement} className="flex min-w-0 flex-1 bg-theme-background !outline-none" tabIndex={-1}>
<Outlet />
</main>
</div>

View File

@ -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<HTMLDivElement | null>(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<boolean>(false)
const [
IsStartOfElementProviderInternal,
useIsSoFWrappedElement,
useSetIsSOfElement,
] = createContextState<boolean>(false)
const Providers = [
<WrappedElementProviderInternal key="ArticleElementProviderInternal" />,
<ElementSizeProviderInternal key="ElementSizeProviderInternal" />,
<ElementPositionProviderInternal key="ElementPositionProviderInternal" />,
<IsEndOfElementProviderInternal key="IsEndOfElementProviderInternal" />,
<IsStartOfElementProviderInternal key="IsStartOfElementProviderInternal" />,
]
interface WrappedElementProviderProps {
boundingDetection?: boolean
as?: keyof React.JSX.IntrinsicElements
}
export const WrappedElementProvider: Component<WrappedElementProviderProps> = ({
children,
className,
...props
}) => (
<ProviderComposer contexts={Providers}>
<ArticleElementResizeObserver />
<Content {...props} className={className}>
{children}
</Content>
</ProviderComposer>
)
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<WrappedElementProviderProps> = memo(
({ children, className, boundingDetection, as = "div" }) => {
const setElement = useSetWrappedElement()
const As = as as any
return (
<As className={cn("relative", className)} ref={setElement}>
{boundingDetection && <BoundingDetection bounding="start" />}
{children}
{boundingDetection && <BoundingDetection bounding="end" />}
</As>
)
},
)
Content.displayName = "ArticleElementProviderContent"
const BoundingDetection: Component<{
bounding: "start" | "end"
}> = ({ bounding }) => {
const endSetter = useSetIsEOfElement()
const startSetter = useSetIsSOfElement()
const ref = useRef<HTMLDivElement>(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 <div ref={ref} />
}
export {
useIsEoFWrappedElement,
useIsSoFWrappedElement,
useSetWrappedElement,
useWrappedElement,
useWrappedElementPosition,
useWrappedElementSize,
}