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 { useQuoteReply } from './use-quote-reply'
|
||||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
import { CarouselProvider } from './carousel'
|
||||||
|
|
||||||
function BioItem ({ item, handleClick }) {
|
function BioItem ({ item, handleClick }) {
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
|
@ -156,20 +157,22 @@ export default function ItemFull ({ item, bio, rank, ...props }) {
|
||||||
</div>)
|
</div>)
|
||||||
: <div />}
|
: <div />}
|
||||||
<RootProvider root={item.root || item}>
|
<RootProvider root={item.root || item}>
|
||||||
{item.parentId
|
<CarouselProvider key={item.id}>
|
||||||
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
{item.parentId
|
||||||
: (
|
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
|
||||||
<div>{bio
|
: (
|
||||||
? <BioItem item={item} {...props} />
|
<div>{bio
|
||||||
: <TopLevelItem item={item} {...props} />}
|
? <BioItem item={item} {...props} />
|
||||||
</div>)}
|
: <TopLevelItem item={item} {...props} />}
|
||||||
{item.comments &&
|
</div>)}
|
||||||
<div className={styles.comments}>
|
{item.comments &&
|
||||||
<Comments
|
<div className={styles.comments}>
|
||||||
parentId={item.id} parentCreatedAt={item.createdAt}
|
<Comments
|
||||||
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
parentId={item.id} parentCreatedAt={item.createdAt}
|
||||||
/>
|
pinned={item.position} bio={bio} commentSats={item.commentSats} comments={item.comments}
|
||||||
</div>}
|
/>
|
||||||
|
</div>}
|
||||||
|
</CarouselProvider>
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
import styles from './text.module.css'
|
import styles from './text.module.css'
|
||||||
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useCallback, memo, useRef } from 'react'
|
||||||
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url'
|
import { decodeProxyUrl, IMGPROXY_URL_REGEXP, MEDIA_DOMAIN_REGEXP, parseEmbedUrl } from '@/lib/url'
|
||||||
import { useShowModal } from './modal'
|
|
||||||
import { useMe } from './me'
|
import { useMe } from './me'
|
||||||
import { Button, Dropdown } from 'react-bootstrap'
|
import { Button } from 'react-bootstrap'
|
||||||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
import { TwitterTweetEmbed } from 'react-twitter-embed'
|
||||||
import YouTube from 'react-youtube'
|
import YouTube from 'react-youtube'
|
||||||
import useDarkMode from './dark-mode'
|
import useDarkMode from './dark-mode'
|
||||||
|
import { useCarousel } from './carousel'
|
||||||
|
|
||||||
function LinkRaw ({ href, children, src, rel }) {
|
function LinkRaw ({ href, children, src, rel }) {
|
||||||
const isRawURL = /^https?:\/\//.test(children?.[0])
|
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 }) {
|
export default function MediaOrLink ({ linkFallback = true, ...props }) {
|
||||||
const media = useMediaHelper(props)
|
const media = useMediaHelper(props)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const showModal = useShowModal()
|
const { showCarousel, addMedia } = useCarousel()
|
||||||
|
|
||||||
const handleClick = useCallback(() => showModal(close => {
|
useEffect(() => {
|
||||||
return (
|
if (!media.image) return
|
||||||
<div
|
addMedia({ src: media.bestResSrc, originalSrc: media.originalSrc, rel: props.rel })
|
||||||
className={styles.fullScreenContainer}
|
}, [media.image])
|
||||||
onClick={close}
|
|
||||||
>
|
const handleClick = useCallback(() => showCarousel({ src: media.bestResSrc }),
|
||||||
<img className={styles.fullScreen} src={media.bestResSrc} />
|
[showCarousel, 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])
|
|
||||||
|
|
||||||
const handleError = useCallback((err) => {
|
const handleError = useCallback((err) => {
|
||||||
console.error('Error loading media', err)
|
console.error('Error loading media', err)
|
||||||
|
|
|
@ -36,6 +36,14 @@ export default function useModal () {
|
||||||
forceUpdate()
|
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
|
// this is called on every navigation due to below useEffect
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
while (modalStack.current.length) {
|
while (modalStack.current.length) {
|
||||||
|
@ -94,7 +102,7 @@ export default function useModal () {
|
||||||
|
|
||||||
const showModal = useCallback(
|
const showModal = useCallback(
|
||||||
(getContent, options) => {
|
(getContent, options) => {
|
||||||
const ref = { node: getContent(onClose), options }
|
const ref = { node: getContent(onClose, setOptions), options }
|
||||||
if (options?.replaceModal) {
|
if (options?.replaceModal) {
|
||||||
modalStack.current = [ref]
|
modalStack.current = [ref]
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -23,6 +23,7 @@ import isEqual from 'lodash/isEqual'
|
||||||
import UserPopover from './user-popover'
|
import UserPopover from './user-popover'
|
||||||
import ItemPopover from './item-popover'
|
import ItemPopover from './item-popover'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
|
import { CarouselProvider, useCarousel } from './carousel'
|
||||||
|
|
||||||
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
|
// Explicitely defined start/end tags & which CSS class from text.module.css to apply
|
||||||
export const rehypeSuperscript = () => rehypeStyler('<sup>', '</sup>', styles.superscript)
|
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
|
return url
|
||||||
}
|
}
|
||||||
const srcSet = imgproxyUrls?.[url]
|
const srcSet = imgproxyUrls?.[url]
|
||||||
|
|
||||||
return <MediaOrLink srcSet={srcSet} tab={tab} src={src} rel={rel ?? UNKNOWN_LINK_REL} {...props} topLevel={topLevel} />
|
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(() => ({
|
const components = useMemo(() => ({
|
||||||
h1: Heading,
|
h1: Heading,
|
||||||
|
@ -261,16 +263,29 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
||||||
|
|
||||||
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
const remarkPlugins = useMemo(() => [gfm, mention, sub], [])
|
||||||
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
const rehypePlugins = useMemo(() => [rehypeInlineCodeProperty, rehypeSuperscript, rehypeSubscript], [])
|
||||||
|
const carousel = useCarousel()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}>
|
<div className={classNames(styles.text, topLevel && styles.topLevel, show ? styles.textUncontained : overflowing && styles.textContained)} ref={containerRef}>
|
||||||
<ReactMarkdown
|
{carousel && tab !== 'preview'
|
||||||
components={components}
|
? (
|
||||||
remarkPlugins={remarkPlugins}
|
<ReactMarkdown
|
||||||
rehypePlugins={rehypePlugins}
|
components={components}
|
||||||
>
|
remarkPlugins={remarkPlugins}
|
||||||
{children}
|
rehypePlugins={rehypePlugins}
|
||||||
</ReactMarkdown>
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>)
|
||||||
|
: (
|
||||||
|
<CarouselProvider>
|
||||||
|
<ReactMarkdown
|
||||||
|
components={components}
|
||||||
|
remarkPlugins={remarkPlugins}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</CarouselProvider>)}
|
||||||
{overflowing && !show &&
|
{overflowing && !show &&
|
||||||
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
|
<Button size='lg' variant='info' className={styles.textShowFull} onClick={() => setShow(true)}>
|
||||||
show full text
|
show full text
|
||||||
|
|
|
@ -233,26 +233,6 @@
|
||||||
max-height: 35vh;
|
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 {
|
.text table {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -272,9 +272,9 @@ $zindex-sticky: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0% {
|
||||||
opacity: 42%;
|
opacity: 42%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
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