Skip to content

Commit

Permalink
feat: support read indicator and back to top, fixed RSSNext#2040
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <tukon479@gmail.com>
  • Loading branch information
Innei committed Dec 9, 2024
1 parent 76fc17e commit dac1a47
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 36 deletions.
41 changes: 30 additions & 11 deletions apps/renderer/src/components/ux/transition/icon.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { cn } from "@follow/utils/utils"
import type { Target } from "framer-motion"
import { AnimatePresence, m } from "framer-motion"
import { useEffect, useState } from "react"
import * as React from "react"
import { cloneElement, useEffect, useState } from "react"

type TransitionType = {
initial: Target | boolean
Expand All @@ -10,8 +11,8 @@ type TransitionType = {
}

type IconTransitionProps = {
icon1: string
icon2: string
icon1: string | React.JSX.Element
icon2: string | React.JSX.Element
status: "init" | "done"
className?: string
icon1ClassName?: string
Expand All @@ -34,21 +35,39 @@ const createIconTransition =
return (
<AnimatePresence mode="popLayout">
{status === "init" ? (
<m.i
className={cn(icon1ClassName, className, icon1)}
key="1"
initial={initial}
animate={animate}
exit={exit}
/>
) : (
typeof icon1 === "string" ? (
<m.i
className={cn(icon1ClassName, className, icon1)}
key="1"
initial={initial}
animate={animate}
exit={exit}
/>
) : (
cloneElement(icon1, {
className: cn(icon1ClassName, className),
key: "1",
initial,
animate,
exit,
})
)
) : typeof icon2 === "string" ? (
<m.i
className={cn(icon2ClassName, className, icon2)}
key="2"
initial={initial}
animate={animate}
exit={exit}
/>
) : (
cloneElement(icon2, {
className: cn(icon2ClassName, className),
key: "2",
initial,
animate,
exit,
})
)}
</AnimatePresence>
)
Expand Down
6 changes: 5 additions & 1 deletion apps/renderer/src/hooks/biz/useNavigateEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,9 @@ export const navigateEntry = (options: NavigateEntryOptions) => {
} else {
if (!isMobile()) path += `/${ROUTE_ENTRY_PENDING}`
}
return getStableRouterNavigate()?.(`${path}?${nextSearchParams.toString()}`)

const finalPath = `${path}?${nextSearchParams.toString()}`
const currentPath = getReadonlyRoute().location.pathname + getReadonlyRoute().location.search
if (finalPath === currentPath) return
return getStableRouterNavigate()?.(finalPath)
}
123 changes: 102 additions & 21 deletions apps/renderer/src/modules/entry-content/index.shared.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { getViewport } from "@follow/components/hooks/useViewport.js"
import {
CircleProgress,
MaterialSymbolsProgressActivity,
} from "@follow/components/icons/Progress.js"
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js"
import { Button, MotionButtonBase } from "@follow/components/ui/button/index.js"
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import { EventBus } from "@follow/utils/event-bus"
import { springScrollTo } from "@follow/utils/scroller"
import { cn } from "@follow/utils/utils"
import type { FallbackRender } from "@sentry/react"
import type { FC } from "react"
import { memo, useEffect, useLayoutEffect, useRef } from "react"
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"

import { useShowAISummary } from "~/atoms/ai-summary"
Expand All @@ -18,12 +26,17 @@ import {
} from "~/atoms/readability"
import { enableShowSourceContent } from "~/atoms/source-content"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { IconOpacityTransition } from "~/components/ux/transition/icon"
import { isWebBuild } from "~/constants"
import { useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { getNewIssueUrl } from "~/lib/issues"
import { useIsSoFWrappedElement, useWrappedElement } from "~/providers/wrapped-element-provider"
import {
useIsSoFWrappedElement,
useWrappedElement,
useWrappedElementSize,
} from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
Expand Down Expand Up @@ -183,31 +196,64 @@ export const RenderError: FallbackRender = ({ error }) => {
<span className="font-sans text-sm">
{t("entry_content.render_error")} {nextError.message}
</span>
<a
href={getNewIssueUrl({
body: [
"### Error",
"",
nextError.message,
"",
"### Stack",
"",
"```",
nextError.stack,
"```",
].join("\n"),
label: "bug",
title: "Render error",
})}
target="_blank"
rel="noreferrer"
<Button
variant={"outline"}
onClick={() => {
window.open(
getNewIssueUrl({
body: [
"### Error",
"",
nextError.message,
"",
"### Stack",
"",
"```",
nextError.stack,
"```",
].join("\n"),
label: "bug",
title: "Render error",
}),
)
}}
>
{t("entry_content.report_issue")}
</a>
</Button>
</div>
)
}

const useReadPercent = () => {
const y = 55
const { h } = useWrappedElementSize()

const scrollElement = useScrollViewElement()
const [scrollTop, setScrollTop] = useState(0)

useEffect(() => {
const handler = () => {
if (scrollElement) {
setScrollTop(scrollElement.scrollTop)
}
}
handler()
scrollElement?.addEventListener("scroll", handler)
return () => {
scrollElement?.removeEventListener("scroll", handler)
}
}, [scrollElement])

const readPercent = useMemo(() => {
const winHeight = getViewport().h
const deltaHeight = Math.min(scrollTop, winHeight)

return Math.floor(Math.min(Math.max(0, ((scrollTop - y + deltaHeight) / h) * 100), 100)) || 0
}, [y, h, scrollTop])

return [readPercent, scrollTop]
}

export const ContainerToc: FC = memo(() => {
const wrappedElement = useWrappedElement()

Expand All @@ -226,12 +272,47 @@ export const ContainerToc: FC = memo(() => {
"@[700px]:-translate-x-12 @[800px]:-translate-x-16 @[900px]:translate-x-0 @[900px]:items-start",
)}
/>

<BackTopIndicator />
</div>
</div>
</RootPortal>
)
})

const BackTopIndicator = memo(() => {
const [readPercent] = useReadPercent()
const scrollElement = useScrollViewElement()
return (
<span
className={
"mt-2 flex grow flex-col px-2 font-sans text-sm text-gray-800 dark:text-neutral-300"
}
>
<div className="flex items-center gap-2 tabular-nums">
<IconOpacityTransition
icon1={<MaterialSymbolsProgressActivity />}
icon2={<CircleProgress percent={readPercent} size={14} strokeWidth={2} />}
status={readPercent === 0 ? "init" : "done"}
/>
{readPercent}%<br />
</div>
<MotionButtonBase
onClick={() => {
springScrollTo(0, scrollElement!)
}}
className={cn(
"mt-1 flex flex-nowrap items-center gap-2 opacity-50 transition-all duration-500 hover:opacity-100",
readPercent > 10 ? "" : "pointer-events-none opacity-0",
)}
>
<i className="i-mingcute-arrow-up-circle-line" />
<span className="whitespace-nowrap">Back Top</span>
</MotionButtonBase>
</span>
)
})

export function AISummary({ entryId }: { entryId: string }) {
const { t } = useTranslation()
const entry = useEntry(entryId)
Expand Down
63 changes: 63 additions & 0 deletions packages/components/src/icons/Progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { SVGProps } from "react"

export function MaterialSymbolsProgressActivity(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props}>
<path
fill="currentColor"
d="M12 22q-2.05 0-3.875-.788t-3.187-2.15q-1.363-1.362-2.15-3.187T2 12q0-2.075.788-3.887t2.15-3.175Q6.3 3.575 8.124 2.788T12 2q.425 0 .713.288T13 3q0 .425-.288.713T12 4Q8.675 4 6.337 6.338T4 12q0 3.325 2.338 5.663T12 20q3.325 0 5.663-2.337T20 12q0-.425.288-.712T21 11q.425 0 .713.288T22 12q0 2.05-.788 3.875t-2.15 3.188q-1.362 1.362-3.175 2.15T12 22"
/>
</svg>
)
}

interface CircleProgressProps {
percent: number
size?: number
strokeWidth?: number
strokeColor?: string
backgroundColor?: string
className?: string
}

export const CircleProgress: React.FC<CircleProgressProps> = ({
percent,
size = 100,
strokeWidth = 8,
strokeColor = "currentColor",
backgroundColor = "hsl(var(--background))",
className,
}) => {
const normalizedPercent = Math.min(100, Math.max(0, percent))
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (normalizedPercent / 100) * circumference

return (
<svg width={size} height={size} className={className}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
strokeLinecap="round"
fill="none"
stroke={backgroundColor}
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
strokeLinecap="round"
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
className="duration-75"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: "stroke-dashoffset 0.3s" }}
/>
</svg>
)
}
4 changes: 2 additions & 2 deletions packages/utils/src/scroller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const springScrollTo = (
autoplay: true,
...spring,
onPlay() {
el.addEventListener("wheel", stopSpringScrollHandler)
el.addEventListener("wheel", stopSpringScrollHandler, { capture: true })
el.addEventListener("touchmove", stopSpringScrollHandler)
},

Expand All @@ -38,7 +38,7 @@ export const springScrollTo = (
})

animation.then(() => {
el.removeEventListener("wheel", stopSpringScrollHandler)
el.removeEventListener("wheel", stopSpringScrollHandler, { capture: true })
el.removeEventListener("touchmove", stopSpringScrollHandler)
})

Expand Down
2 changes: 1 addition & 1 deletion packages/utils/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"extends": "../tsconfig.extend.json",
"compilerOptions": {
"baseUrl": ".",
"declaration": true,
"declaration": false,
"types": ["@follow/types/global"],
"paths": {
"@follow/utils/*": ["./src/*"]
Expand Down

0 comments on commit dac1a47

Please sign in to comment.