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:
parent
5371e1abf8
commit
9f79d588a8
|
@ -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)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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,20 +157,22 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
|
|||
</div>)
|
||||
: <div />}
|
||||
<RootProvider root={item.root || item}>
|
||||
{item.parentId
|
||||
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
||||
: (
|
||||
<div>{bio
|
||||
? <BioItem item={item} {...props} />
|
||||
: <TopLevelItem item={item} {...props} />}
|
||||
</div>)}
|
||||
{item.comments &&
|
||||
<div className={styles.comments}>
|
||||
<Comments
|
||||
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
||||
/>
|
||||
</div>}
|
||||
<CarouselProvider key={item.id}>
|
||||
{item.parentId
|
||||
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
||||
: (
|
||||
<div>{bio
|
||||
? <BioItem item={item} {...props} />
|
||||
: <TopLevelItem item={item} {...props} />}
|
||||
</div>)}
|
||||
{item.comments &&
|
||||
<div className={styles.comments}>
|
||||
<Comments
|
||||
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
||||
/>
|
||||
</div>}
|
||||
</CarouselProvider>
|
||||
</RootProvider>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,16 +263,29 @@ 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}>
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
{carousel && tab !== 'preview'
|
||||
? (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>)
|
||||
: (
|
||||
<CarouselProvider>
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</CarouselProvider>)}
|
||||
{overflowing && !show &&
|
||||
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
|
||||
show full text
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -272,9 +272,9 @@ $zindex-sticky: 900;
|
|||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 42%;
|
||||
}
|
||||
0% {
|
||||
opacity: 42%;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
|
|
|
@ -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 |
Loading…
Reference in New Issue