Image carousel (#1425)

* Add image carousel in fullscreen

* Flip through all images of a post

* Disable image selection in fullscreen

* Keep max-width: 100vw for images

* Fix missing dependency

* fix merge resolve bug

* better css

* refactor, keypress/swipe events, remove scoll

* changes after self-review

* give previews their own carousel

* hooks for arrow keys and swiping

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
ekzyis 2024-09-27 00:37:13 +02:00 committed by GitHub
parent 5371e1abf8
commit 9f79d588a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 255 additions and 68 deletions

129
components/carousel.js Normal file
View File

@ -0,0 +1,129 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import classNames from 'classnames'
import ArrowLeft from '@/svgs/arrow-left-line.svg'
import ArrowRight from '@/svgs/arrow-right-line.svg'
import styles from './carousel.module.css'
import { useShowModal } from './modal'
import { Dropdown } from 'react-bootstrap'
function useSwiping ({ moveLeft, moveRight }) {
const [touchStartX, setTouchStartX] = useState(null)
const onTouchStart = useCallback((e) => {
if (e.touches.length === 1) {
setTouchStartX(e.touches[0].clientX)
}
}, [])
const onTouchEnd = useCallback((e) => {
if (touchStartX !== null) {
const touchEndX = e.changedTouches[0].clientX
const diff = touchEndX - touchStartX
if (diff > 50) {
moveLeft()
} else if (diff < -50) {
moveRight()
}
setTouchStartX(null)
}
}, [touchStartX, moveLeft, moveRight])
useEffect(() => {
document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchend', onTouchEnd)
return () => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchend', onTouchEnd)
}
}, [onTouchStart, onTouchEnd])
}
function useArrowKeys ({ moveLeft, moveRight }) {
const onKeyDown = useCallback((e) => {
if (e.key === 'ArrowLeft') {
moveLeft()
} else if (e.key === 'ArrowRight') {
moveRight()
}
}, [moveLeft, moveRight])
useEffect(() => {
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onKeyDown])
}
export default function Carousel ({ close, mediaArr, src, originalSrc, setOptions }) {
const [index, setIndex] = useState(mediaArr.findIndex(([key]) => key === src))
const [currentSrc, canGoLeft, canGoRight] = useMemo(() => {
return [mediaArr[index][0], index > 0, index < mediaArr.length - 1]
}, [mediaArr, index])
const moveLeft = useCallback(() => {
setIndex(i => Math.max(0, i - 1))
}, [setIndex])
const moveRight = useCallback(() => {
setIndex(i => Math.min(mediaArr.length - 1, i + 1))
}, [setIndex, mediaArr.length])
useSwiping({ moveLeft, moveRight })
useArrowKeys({ moveLeft, moveRight })
return (
<div className={styles.fullScreenContainer} onClick={close}>
<img className={styles.fullScreen} src={currentSrc} />
<div className={styles.fullScreenNavContainer}>
<div
className={classNames(styles.fullScreenNav, !canGoLeft && 'invisible', styles.left)}
onClick={(e) => {
e.stopPropagation()
moveLeft()
}}
>
<ArrowLeft width={34} height={34} />
</div>
<div
className={classNames(styles.fullScreenNav, !canGoRight && 'invisible', styles.right)}
onClick={(e) => {
e.stopPropagation()
moveRight()
}}
>
<ArrowRight width={34} height={34} />
</div>
</div>
</div>
)
}
const CarouselContext = createContext()
function CarouselOverflow ({ originalSrc, rel }) {
return <Dropdown.Item href={originalSrc} rel={rel} target='_blank'>view original</Dropdown.Item>
}
export function CarouselProvider ({ children }) {
const media = useRef(new Map())
const showModal = useShowModal()
const showCarousel = useCallback(({ src }) => {
showModal((close, setOptions) => {
return <Carousel close={close} mediaArr={Array.from(media.current.entries())} src={src} setOptions={setOptions} />
}, {
fullScreen: true,
overflow: <CarouselOverflow {...media.current.get(src)} />
})
}, [showModal, media.current])
const addMedia = useCallback(({ src, originalSrc, rel }) => {
media.current.set(src, { src, originalSrc, rel })
}, [media.current])
const value = useMemo(() => ({ showCarousel, addMedia }), [showCarousel, addMedia])
return <CarouselContext.Provider value={value}>{children}</CarouselContext.Provider>
}
export function useCarousel () {
return useContext(CarouselContext)
}

View File

@ -0,0 +1,63 @@
div.fullScreenNavContainer {
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
pointer-events: none;
flex-direction: row;
display: flex;
justify-content: space-between;
align-items: center;
}
img.fullScreen {
cursor: zoom-out !important;
max-height: 100%;
max-width: 100vw;
min-width: 0;
min-height: 0;
align-self: center;
justify-self: center;
user-select: none;
}
.fullScreenContainer {
--bs-columns: 1;
--bs-rows: 1;
display: grid;
width: 100%;
height: 100%;
}
div.fullScreenNav:hover > svg {
background-color: rgba(0, 0, 0, .5);
}
div.fullScreenNav {
cursor: pointer;
pointer-events: auto;
width: 72px;
height: 72px;
display: flex;
align-items: center;
}
div.fullScreenNav.left {
justify-content: flex-start;
}
div.fullScreenNav.right {
justify-content: flex-end;
}
div.fullScreenNav > svg {
border-radius: 50%;
backdrop-filter: blur(4px);
background-color: rgba(0, 0, 0, 0.7);
fill: white;
max-height: 34px;
max-width: 34px;
padding: 0.35rem;
margin: .75rem;
}

View File

@ -24,6 +24,7 @@ import { numWithUnits } from '@/lib/format'
import { useQuoteReply } from './use-quote-reply'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
import { CarouselProvider } from './carousel'
function BioItem ({ item, handleClick }) {
const { me } = useMe()
@ -156,6 +157,7 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
</div>)
: <div />}
<RootProvider root={item.root || item}>
<CarouselProvider key={item.id}>
{item.parentId
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
: (
@ -170,6 +172,7 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
/>
</div>}
</CarouselProvider>
</RootProvider>
</>
)

View File

@ -1,14 +1,14 @@
import styles from './text.module.css'
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url'
import { useShowModal } from './modal'
import { useMe } from './me'
import { Button, Dropdown } from 'react-bootstrap'
import { Button } from 'react-bootstrap'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import classNames from 'classnames'
import { TwitterTweetEmbed } from 'react-twitter-embed'
import YouTube from 'react-youtube'
import useDarkMode from './dark-mode'
import { useCarousel } from './carousel'
function LinkRaw ({ href, children, src, rel }) {
const isRawURL = /^https?:\/\//.test(children?.[0])
@ -52,27 +52,15 @@ const Media = memo(function Media ({ src, bestResSrc, srcSet, sizes, width, heig
export default function MediaOrLink ({ linkFallback = true, ...props }) {
const media = useMediaHelper(props)
const [error, setError] = useState(false)
const showModal = useShowModal()
const { showCarousel, addMedia } = useCarousel()
const handleClick = useCallback(() => showModal(close => {
return (
<div
className={styles.fullScreenContainer}
onClick={close}
>
<img className={styles.fullScreen} src={media.bestResSrc} />
</div>
)
}, {
fullScreen: true,
overflow: (
<Dropdown.Item
href={media.originalSrc} target='_blank'
rel={props.rel ?? UNKNOWN_LINK_REL}
>
open original
</Dropdown.Item>)
}), [showModal, media.originalSrc, styles, media.bestResSrc])
useEffect(() => {
if (!media.image) return
addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
}, [media.image])
const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }),
[showCarousel, media.bestResSrc])
const handleError = useCallback((err) => {
console.error('Error loading media', err)

View File

@ -36,6 +36,14 @@ export default function useModal () {
forceUpdate()
}, [])
const setOptions = useCallback(options => {
const current = getCurrentContent()
if (current) {
current.options = { ...current.options, ...options }
forceUpdate()
}
}, [getCurrentContent, forceUpdate])
// this is called on every navigation due to below useEffect
const onClose = useCallback(() => {
while (modalStack.current.length) {
@ -94,7 +102,7 @@ export default function useModal () {
const showModal = useCallback(
(getContent, options) => {
const ref = { node: getContent(onClose), options }
const ref = { node: getContent(onClose, setOptions), options }
if (options?.replaceModal) {
modalStack.current = [ref]
} else {

View File

@ -23,6 +23,7 @@ import isEqual from 'lodash/isEqual'
import UserPopover from './user-popover'
import ItemPopover from './item-popover'
import classNames from 'classnames'
import { CarouselProvider, useCarousel } from './carousel'
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript)
@ -148,8 +149,9 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
return url
}
const srcSet = imgproxyUrls?.[url]
return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
}, [imgproxyUrls, topLevel, tab])
}, [imgproxyUrls, topLevel, tab, outlawed, rel])
const components = useMemo(() => ({
h1: Heading,
@ -261,9 +263,21 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
const carousel = useCarousel()
return (
<div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}>
{carousel && tab !== 'preview'
? (
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
>
{children}
</ReactMarkdown>)
: (
<CarouselProvider>
<ReactMarkdown
components={components}
remarkPlugins={remarkPlugins}
@ -271,6 +285,7 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
>
{children}
</ReactMarkdown>
</CarouselProvider>)}
{overflowing && !show &&
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
show full text

View File

@ -233,26 +233,6 @@
max-height: 35vh;
}
img.fullScreen {
cursor: zoom-out !important;
max-height: 100%;
max-width: 100vw;
min-width: 0;
min-height: 0;
align-self: center;
justify-self: center;
}
.fullScreenContainer {
--bs-columns: 1;
--bs-rows: 1;
display: grid;
width: 100%;
height: 100%;
align-content: center;
justify-content: center;
}
.text table {
width: auto;
}

View File

@ -272,9 +272,9 @@ $zindex-sticky: 900;
}
@keyframes pulse {
0% {
0% {
opacity: 42%;
}
}
}
svg {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.1716 10.9999L10.8076 5.63589L12.2218 4.22168L20 11.9999L12.2218 19.778L10.8076 18.3638L16.1716 12.9999H4V10.9999H16.1716Z"></path></svg>

After

Width:  |  Height:  |  Size: 229 B