Skip to content

Commit e0d3e17

Browse files
committed
feat: entry image gallery modal
Signed-off-by: Innei <tukon479@gmail.com>
1 parent a92ef49 commit e0d3e17

File tree

10 files changed

+252
-125
lines changed

10 files changed

+252
-125
lines changed

apps/renderer/src/modules/entry-column/Items/picture-item.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import {
2+
MasonryIntersectionContext,
3+
useMasonryItemRatio,
4+
useMasonryItemWidth,
5+
useSetStableMasonryItemRatio,
6+
} from "@follow/components/ui/masonry/contexts.jsx"
17
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
28
import { FeedViewType } from "@follow/constants"
39
import { cn } from "@follow/utils/utils"
@@ -22,12 +28,6 @@ import { usePreviewMedia } from "../../../components/ui/media/hooks"
2228
import { EntryItemWrapper } from "../layouts/EntryItemWrapper"
2329
import { GridItem, GridItemFooter } from "../templates/grid-item-template"
2430
import type { EntryItemStatelessProps, UniversalItemProps } from "../types"
25-
import {
26-
MasonryIntersectionContext,
27-
useMasonryItemRatio,
28-
useMasonryItemWidth,
29-
useSetStableMasonryItemRatio,
30-
} from "./contexts/picture-masonry-context"
3131

3232
export function PictureItem({ entryId, entryPreview, translation }: UniversalItemProps) {
3333
const entry = useEntry(entryId) || entryPreview

apps/renderer/src/modules/entry-column/Items/picture-masonry.tsx

Lines changed: 11 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
import {
2+
MasonryIntersectionContext,
3+
MasonryItemsAspectRatioContext,
4+
MasonryItemsAspectRatioSetterContext,
5+
MasonryItemWidthContext,
6+
} from "@follow/components/ui/masonry/contexts.jsx"
7+
import { useMasonryColumn } from "@follow/components/ui/masonry/hooks.js"
18
import { Masonry } from "@follow/components/ui/masonry/index.js"
29
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
310
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
411
import { useRefValue } from "@follow/hooks"
5-
import { nextFrame } from "@follow/utils/dom"
6-
import { throttle } from "lodash-es"
712
import type { RenderComponentProps } from "masonic"
813
import { useInfiniteLoader } from "masonic"
914
import type { FC } from "react"
10-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
15+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
1116
import { useEventCallback } from "usehooks-ts"
1217

1318
import { useGeneralSettingKey } from "~/atoms/settings/general"
@@ -16,53 +21,17 @@ import { getEntry } from "~/store/entry"
1621
import { imageActions } from "~/store/image"
1722

1823
import { batchMarkRead } from "../hooks"
19-
import {
20-
MasonryIntersectionContext,
21-
MasonryItemsAspectRatioContext,
22-
MasonryItemsAspectRatioSetterContext,
23-
MasonryItemWidthContext,
24-
} from "./contexts/picture-masonry-context"
2524
import { PictureWaterFallItem } from "./picture-item"
2625

2726
// grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5
2827

29-
const breakpoints = {
30-
0: 1,
31-
// 32rem => 32 * 16= 512
32-
512: 2,
33-
// 48rem => 48 * 16= 768
34-
768: 3,
35-
// 72rem => 72 * 16= 1152
36-
1152: 4,
37-
// 80rem => 80 * 16= 1280
38-
1280: 5,
39-
1536: 6,
40-
1792: 7,
41-
2048: 8,
42-
}
43-
const getCurrentColumn = (w: number) => {
44-
// Initialize column count with the minimum number of columns
45-
let columns = 1
46-
47-
// Iterate through each breakpoint and determine the column count
48-
for (const [breakpoint, cols] of Object.entries(breakpoints)) {
49-
if (w >= Number.parseInt(breakpoint)) {
50-
columns = cols
51-
} else {
52-
break
53-
}
54-
}
55-
56-
return columns
57-
}
5828
const gutter = 24
5929

6030
export const PictureMasonry: FC<MasonryProps> = (props) => {
6131
const { data } = props
6232
const cacheMap = useState(() => new Map<string, object>())[0]
6333
const [isInitDim, setIsInitDim] = useState(false)
6434
const [isInitLayout, setIsInitLayout] = useState(false)
65-
const [currentItemWidth, setCurrentItemWidth] = useState(0)
6635
const restoreDimensions = useEventCallback(async () => {
6736
const images = [] as string[]
6837
data.forEach((entryId) => {
@@ -78,42 +47,10 @@ export const PictureMasonry: FC<MasonryProps> = (props) => {
7847
setIsInitDim(true)
7948
})
8049
}, [])
81-
const containerRef = useRef<HTMLDivElement>(null)
82-
const [currentColumn, setCurrentColumn] = useState(1)
83-
84-
useLayoutEffect(() => {
85-
const $warpper = containerRef.current
86-
if (!$warpper) return
87-
const handler = () => {
88-
const column = getCurrentColumn($warpper.clientWidth)
89-
setCurrentItemWidth(Math.trunc($warpper.clientWidth / column - gutter))
90-
91-
setCurrentColumn(column)
92-
93-
nextFrame(() => {
94-
setIsInitLayout(true)
95-
})
96-
}
97-
const recal = throttle(handler, 1000 / 12)
98-
99-
let previousWidth = $warpper.offsetWidth
100-
const resizeObserver = new ResizeObserver((entries) => {
101-
for (const entry of entries) {
102-
const newWidth = entry.contentRect.width
103-
104-
if (newWidth !== previousWidth) {
105-
previousWidth = newWidth
10650

107-
recal()
108-
}
109-
}
110-
})
111-
recal()
112-
resizeObserver.observe($warpper)
113-
return () => {
114-
resizeObserver.disconnect()
115-
}
116-
}, [])
51+
const { containerRef, currentColumn, currentItemWidth } = useMasonryColumn(gutter, () => {
52+
setIsInitLayout(true)
53+
})
11754

11855
const items = useMemo(() => {
11956
const result = data.map((entryId) => {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
MasonryItemsAspectRatioContext,
3+
MasonryItemsAspectRatioSetterContext,
4+
MasonryItemWidthContext,
5+
useMasonryItemRatio,
6+
useMasonryItemWidth,
7+
useSetStableMasonryItemRatio,
8+
} from "@follow/components/ui/masonry/contexts.jsx"
9+
import { useMasonryColumn } from "@follow/components/ui/masonry/hooks.js"
10+
import type { MediaModel } from "@follow/models/types"
11+
import type { RenderComponentProps } from "masonic"
12+
import { Masonry } from "masonic"
13+
import type { PropsWithChildren } from "react"
14+
import { useEffect, useMemo, useState } from "react"
15+
16+
import { Media, MediaContainerWidthProvider } from "~/components/ui/media"
17+
import { useImageDimensions } from "~/store/image"
18+
19+
const gutter = 24
20+
21+
const Render: React.ComponentType<
22+
RenderComponentProps<{
23+
url: string
24+
type: "photo" | "video"
25+
height?: number
26+
width?: number
27+
blurhash?: string
28+
}>
29+
> = ({ data }) => {
30+
const { url, type, height, width, blurhash } = data
31+
32+
const itemWidth = useMasonryItemWidth()
33+
34+
return (
35+
<MasonryItemFixedDimensionWrapper url={url}>
36+
<Media
37+
thumbnail
38+
src={url}
39+
type={type}
40+
className="size-full overflow-hidden"
41+
mediaContainerClassName={"w-auto h-auto rounded"}
42+
loading="lazy"
43+
proxy={{
44+
width: itemWidth,
45+
height: 0,
46+
}}
47+
height={height}
48+
width={width}
49+
blurhash={blurhash}
50+
/>
51+
</MasonryItemFixedDimensionWrapper>
52+
)
53+
}
54+
export const ImageGallery = ({ images }: { images: MediaModel[] }) => {
55+
const { containerRef, currentColumn, currentItemWidth } = useMasonryColumn(gutter)
56+
57+
const [masonryItemsRadio, setMasonryItemsRadio] = useState<Record<string, number>>({})
58+
return (
59+
<div ref={containerRef}>
60+
<MasonryItemWidthContext.Provider value={currentItemWidth}>
61+
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
62+
<MasonryItemsAspectRatioSetterContext.Provider value={setMasonryItemsRadio}>
63+
<MediaContainerWidthProvider width={currentItemWidth}>
64+
<Masonry<(typeof images)[number]>
65+
items={images}
66+
columnGutter={gutter}
67+
columnWidth={currentItemWidth}
68+
columnCount={currentColumn}
69+
overscanBy={2}
70+
render={Render as any}
71+
/>
72+
</MediaContainerWidthProvider>
73+
</MasonryItemsAspectRatioSetterContext.Provider>
74+
</MasonryItemsAspectRatioContext.Provider>
75+
</MasonryItemWidthContext.Provider>
76+
</div>
77+
)
78+
}
79+
80+
const MasonryItemFixedDimensionWrapper = (
81+
props: PropsWithChildren<{
82+
url: string
83+
}>,
84+
) => {
85+
const { url, children } = props
86+
const dim = useImageDimensions(url)
87+
const itemWidth = useMasonryItemWidth()
88+
89+
const itemHeight = dim ? itemWidth / dim.ratio : itemWidth
90+
const stableRadio = useState(() => itemWidth / itemHeight || 1)[0]
91+
const setItemStableRatio = useSetStableMasonryItemRatio()
92+
93+
const stableRadioCtx = useMasonryItemRatio(url)
94+
95+
useEffect(() => {
96+
setItemStableRatio(url, stableRadio)
97+
}, [setItemStableRatio, stableRadio, url])
98+
99+
const style = useMemo(
100+
() => ({
101+
width: itemWidth,
102+
height: itemWidth / stableRadioCtx,
103+
}),
104+
[itemWidth, stableRadioCtx],
105+
)
106+
107+
if (!style.height) return null
108+
109+
return (
110+
<div className="relative flex h-full gap-2 overflow-x-auto overflow-y-hidden" style={style}>
111+
{children}
112+
</div>
113+
)
114+
}

apps/renderer/src/modules/entry-content/header.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ActionButton } from "@follow/components/ui/button/index.js"
22
import { DividerVertical } from "@follow/components/ui/divider/index.js"
33
import { FeedViewType, views } from "@follow/constants"
4-
import type { CombinedEntryModel } from "@follow/models/types"
4+
import type { CombinedEntryModel, MediaModel } from "@follow/models/types"
55
import { IN_ELECTRON } from "@follow/shared/constants"
66
import { cn } from "@follow/utils/utils"
77
import { Slot } from "@radix-ui/react-slot"
@@ -17,15 +17,18 @@ import {
1717
useEntryInReadabilityStatus,
1818
} from "~/atoms/readability"
1919
import { useUISettingKey } from "~/atoms/settings/ui"
20+
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
2021
import { shortcuts } from "~/constants/shortcuts"
2122
import { useEntryActions, useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
2223
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
2324
import { tipcClient } from "~/lib/client"
2425
import { parseHtml } from "~/lib/parse-html"
26+
import { filterSmallMedia } from "~/lib/utils"
2527
import type { FlatEntryModel } from "~/store/entry"
2628
import { useEntry } from "~/store/entry/hooks"
2729
import { useFeedById } from "~/store/feed"
2830

31+
import { ImageGallery } from "./actions/picture-gallery"
2932
import { useEntryContentScrollToTop, useEntryTitleMeta } from "./atoms"
3033
import { EntryReadHistory } from "./components/EntryReadHistory"
3134

@@ -102,6 +105,7 @@ function EntryHeaderImpl({
102105
<div className="relative flex shrink-0 items-center justify-end gap-3">
103106
{!compact && <ElectronAdditionActions view={view} entry={entry} key={entry.entries.id} />}
104107

108+
<SpecialActions id={entry.entries.id} />
105109
{items
106110
.filter((item) => !item.hide)
107111
.map((item) => (
@@ -228,3 +232,26 @@ const ElectronAdditionActions = IN_ELECTRON
228232
: noop
229233

230234
export const EntryHeader = memo(EntryHeaderImpl)
235+
236+
const SpecialActions = ({ id }: { id: string }) => {
237+
const images = useEntry(id, (entry) => entry.entries.media)
238+
const { present } = useModalStack()
239+
const filteredImages = filterSmallMedia(images)
240+
if (filteredImages?.length && filteredImages.length > 5) {
241+
return (
242+
<ActionButton
243+
onClick={() => {
244+
window.analytics?.capture("entry_content_header_image_gallery_click")
245+
present({
246+
title: "Image Gallery",
247+
content: () => <ImageGallery images={filteredImages as any as MediaModel[]} />,
248+
max: true,
249+
})
250+
}}
251+
icon={<i className="i-mgc-pic-cute-fi" />}
252+
tooltip={`Image Gallery`}
253+
/>
254+
)
255+
}
256+
return null
257+
}

apps/server/client/components/items/picture-masonry-context.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

apps/server/client/components/items/picture.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
import {
2-
MasonryItemsAspectRatioContext,
3-
MasonryItemsAspectRatioSetterContext,
4-
MasonryItemWidthContext,
5-
} from "@client/components/items/picture-masonry-context"
61
import { TeleportalTakeOff } from "@client/components/layout/main/teleportal"
72
import { LazyImage } from "@client/components/ui/image"
83
import { getPreferredTitle } from "@client/lib/helper"
@@ -11,6 +6,14 @@ import type { Feed } from "@client/query/feed"
116
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.jsx"
127
import { FeedIcon } from "@follow/components/ui/feed-icon/index.jsx"
138
import { TitleMarquee } from "@follow/components/ui/marquee/index.jsx"
9+
import {
10+
MasonryItemsAspectRatioContext,
11+
MasonryItemsAspectRatioSetterContext,
12+
MasonryItemWidthContext,
13+
useMasonryItemRatio,
14+
useMasonryItemWidth,
15+
useSetStableMasonryItemRatio,
16+
} from "@follow/components/ui/masonry/contexts.jsx"
1417
import { Masonry } from "@follow/components/ui/masonry/index.jsx"
1518
import type { EntryModel } from "@follow/models/types"
1619
import { nextFrame } from "@follow/utils/dom"
@@ -24,12 +27,6 @@ import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "rea
2427
import { PhotoProvider, PhotoView } from "react-photo-view"
2528
import inlineStyle from "react-photo-view/dist/react-photo-view.css?raw"
2629

27-
import {
28-
useMasonryItemRatio,
29-
useMasonryItemWidth,
30-
useSetStableMasonryItemRatio,
31-
} from "./picture-masonry-context"
32-
3330
const MasonryItemFixedDimensionWrapper = (
3431
props: PropsWithChildren<{
3532
url: string

0 commit comments

Comments
 (0)