Skip to content

Commit

Permalink
refactor: picture view masonry (RSSNext#588)
Browse files Browse the repository at this point in the history
* store

* update

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

* resize

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

* cleanup

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

* fix(ci): auto fix

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

* chore: update

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

* update

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

---------

Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei authored Sep 23, 2024
1 parent 2ae12c9 commit d19d5c1
Show file tree
Hide file tree
Showing 11 changed files with 612 additions and 289 deletions.
19 changes: 8 additions & 11 deletions .github/workflows/auto-fix-lint-format-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,31 +36,28 @@ jobs:
- name: Run formatter
run: pnpm run format

- name: Check for changes
id: check_changes
run: |
git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
- name: Commit and push changes
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "chore: auto-fix linting and formatting issues"
commit_options: "--no-verify"
file_pattern: "."

- name: Add PR comment
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
uses: actions/github-script@v7
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const hasChanges = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
}).then(response => response.data.length > 0);
const message = hasChanges
? 'Linting and formatting issues were automatically fixed. Please review the changes.'
: 'No linting or formatting issues were found.';
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: message
body: 'Linting and formatting issues were automatically fixed. Please review the changes.'
});
2 changes: 1 addition & 1 deletion apps/renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@egjs/react-infinitegrid": "4.12.0",
"@egoist/tipc": "0.3.2",
"@electron-toolkit/preload": "^3.0.1",
"@follow/electron-main": "workspace:*",
Expand Down Expand Up @@ -71,6 +70,7 @@
"lethargy": "1.0.9",
"linkedom": "^0.18.5",
"lodash-es": "4.17.21",
"masonic": "4.0.1",
"nanoid": "5.0.7",
"ofetch": "1.4.0",
"path-to-regexp": "8.1.0",
Expand Down
18 changes: 10 additions & 8 deletions apps/renderer/src/components/feed-icon.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from "@radix-ui/react-avatar"
import { m } from "framer-motion"
import type { ReactNode } from "react"
import { forwardRef, useMemo } from "react"

Expand Down Expand Up @@ -145,7 +146,7 @@ export function FeedIcon({

ImageElement = (
<PlatformIcon url={siteUrl} style={sizeStyle} className={cn("center mr-2", className)}>
<img style={sizeStyle} />
<m.img style={sizeStyle} initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
</PlatformIcon>
)
break
Expand All @@ -158,7 +159,12 @@ export function FeedIcon({
})
ImageElement = (
<PlatformIcon url={image} style={sizeStyle} className={cn("center mr-2", className)}>
<img className={cn("mr-2", className)} style={sizeStyle} />
<m.img
className={cn("mr-2", className)}
style={sizeStyle}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
/>
</PlatformIcon>
)
break
Expand Down Expand Up @@ -207,11 +213,7 @@ export function FeedIcon({
if (fallback && !!finalSrc) {
return (
<Avatar className="shrink-0">
<AvatarImage
className="rounded-sm object-cover duration-200 animate-in fade-in-0"
asChild
src={finalSrc}
>
<AvatarImage className="rounded-sm object-cover" asChild src={finalSrc}>
{ImageElement}
</AvatarImage>
<AvatarFallback asChild>{fallbackIcon}</AvatarFallback>
Expand All @@ -224,7 +226,7 @@ export function FeedIcon({
// Else
return (
<Avatar className="shrink-0">
<AvatarImage className="duration-200 animate-in fade-in-0" asChild src={finalSrc}>
<AvatarImage asChild src={finalSrc}>
{ImageElement}
</AvatarImage>
<AvatarFallback>
Expand Down
212 changes: 212 additions & 0 deletions apps/renderer/src/components/ui/Masonry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { clearRequestTimeout, requestTimeout } from "@essentials/request-timeout"
import { useWindowSize } from "@react-hook/window-size"
import { useForceUpdate } from "framer-motion"
import { throttle } from "lodash-es"
import type { ContainerPosition, MasonryProps, MasonryScrollerProps, Positioner } from "masonic"
import { createResizeObserver, useMasonry, usePositioner, useScrollToIndex } from "masonic"
import * as React from "react"

import { useScrollViewElement } from "./scroll-area/hooks"
/**
* A "batteries included" masonry grid which includes all of the implementation details below. This component is the
* easiest way to get off and running in your app, before switching to more advanced implementations, if necessary.
* It will change its column count to fit its container's width and will decide how many rows to render based upon
* the height of the browser `window`.
*
* @param props
*/
export const Masonry = <Item,>(props: MasonryProps<Item>) => {
const [scrollTop, setScrollTop] = React.useState(0)
const [isScrolling, setIsScrolling] = React.useState(false)
const scrollElement = useScrollViewElement()

const fps = props.scrollFps || 12
React.useEffect(() => {
if (!scrollElement) return

const scrollTimer: number | null = null
const handleScroll = throttle(() => {
setIsScrolling(true)
setScrollTop(scrollElement.scrollTop)
}, 1000 / fps)

scrollElement.addEventListener("scroll", handleScroll)

return () => {
scrollElement.removeEventListener("scroll", handleScroll)
if (scrollTimer) {
clearTimeout(scrollTimer)
}
}
}, [fps, scrollElement])
const didMount = React.useRef(0)
React.useEffect(() => {
if (didMount.current === 1) setIsScrolling(true)
let didUnsubscribe = false
const to = requestTimeout(
() => {
if (didUnsubscribe) return
// This is here to prevent premature bail outs while maintaining high resolution
// unsets. Without it there will always bee a lot of unnecessary DOM writes to style.
setIsScrolling(false)
},
40 + 1000 / fps,
)
didMount.current = 1
return () => {
didUnsubscribe = true
clearRequestTimeout(to)
}
}, [fps, scrollTop])

const containerRef = React.useRef<null | HTMLElement>(null)
const windowSize = useWindowSize({
initialWidth: props.ssrWidth,
initialHeight: props.ssrHeight,
})
const containerPos = useContainerPosition(containerRef, windowSize)

const nextProps = Object.assign(
{
offset: containerPos.offset,
width: containerPos.width || windowSize[0],
height: containerPos.height || windowSize[1],
containerRef,
},
props,
) as any

// Workaround for https://github.com/jaredLunde/masonic/issues/12
const itemCounter = React.useRef<number>(props.items.length)

let shrunk = false

if (props.items.length !== itemCounter.current) {
if (props.items.length < itemCounter.current) shrunk = true

itemCounter.current = props.items.length
}

nextProps.positioner = usePositioner(nextProps, [shrunk && Math.random()])

nextProps.resizeObserver = useResizeObserver(nextProps.positioner)
nextProps.scrollTop = scrollTop
nextProps.isScrolling = isScrolling
nextProps.height = window.innerHeight

const scrollToIndex = useScrollToIndex(nextProps.positioner, {
height: nextProps.height,
offset: containerPos.offset,
align: typeof props.scrollToIndex === "object" ? props.scrollToIndex.align : void 0,
})
const index =
props.scrollToIndex &&
(typeof props.scrollToIndex === "number" ? props.scrollToIndex : props.scrollToIndex.index)

React.useEffect(() => {
if (index !== void 0) scrollToIndex(index)
}, [index, scrollToIndex])

return React.createElement(MasonryScroller, nextProps)
}

function MasonryScroller<Item>(
props: MasonryScrollerProps<Item> & {
scrollTop: number
isScrolling: boolean
},
) {
// We put this in its own layer because it's the thing that will trigger the most updates
// and we don't want to slower ourselves by cycling through all the functions, objects, and effects
// of other hooks
// const { scrollTop, isScrolling } = useScroller(props.offset, props.scrollFps)
// This is an update-heavy phase and while we could just Object.assign here,
// it is way faster to inline and there's a relatively low hit to he bundle
// size.

return useMasonry<Item>({
scrollTop: props.scrollTop,
isScrolling: props.isScrolling,
positioner: props.positioner,
resizeObserver: props.resizeObserver,
items: props.items,
onRender: props.onRender,
as: props.as,
id: props.id,
className: props.className,
style: props.style,
role: props.role,
tabIndex: props.tabIndex,
containerRef: props.containerRef,
itemAs: props.itemAs,
itemStyle: props.itemStyle,
itemHeightEstimate: props.itemHeightEstimate,
itemKey: props.itemKey,
overscanBy: props.overscanBy,
height: props.height,
render: props.render,
})
}

function useContainerPosition(
elementRef: React.MutableRefObject<HTMLElement | null>,
deps: React.DependencyList = [],
): ContainerPosition & {
height: number
} {
const [containerPosition, setContainerPosition] = React.useState<
ContainerPosition & {
height: number
}
>({
offset: 0,
width: 0,
height: 0,
})

React.useLayoutEffect(() => {
const { current } = elementRef
if (current !== null) {
let offset = 0
let el = current

do {
offset += el.offsetTop || 0
el = el.offsetParent as HTMLElement
} while (el)

if (offset !== containerPosition.offset || current.offsetWidth !== containerPosition.width) {
setContainerPosition({
offset,
width: current.offsetWidth,
height: current.offsetHeight,
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)

React.useEffect(() => {
const resizeObserver = new ResizeObserver(() => {
setContainerPosition((prev) => ({
...prev,
width: elementRef.current?.offsetWidth || 0,
}))
})
resizeObserver.observe(elementRef.current as HTMLElement)
return () => {
resizeObserver.disconnect()
}
}, [containerPosition, elementRef])

return containerPosition
}

function useResizeObserver(positioner: Positioner) {
const [forceUpdate] = useForceUpdate()
const resizeObserver = createResizeObserver(positioner, throttle(forceUpdate, 1000 / 12))
// Cleans up the resize observers when they change or the
// component unmounts
React.useEffect(() => () => resizeObserver.disconnect(), [resizeObserver])
return resizeObserver
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const useMasonryItemWidth = () => useContext(MasonryItemWidthContext)

export const MasonryItemsAspectRatioContext = createContextSelector({} as Record<string, number>)

export const MasonryIntersectionContext = createContext<IntersectionObserver>(null!)

export const useMasonryItemRatio = (url: string) =>
useContextSelector(MasonryItemsAspectRatioContext, (ctx) => ctx[url])

Expand Down
Loading

0 comments on commit d19d5c1

Please sign in to comment.