diff --git a/components/carousel.js b/components/carousel.js new file mode 100644 index 00000000..666c895b --- /dev/null +++ b/components/carousel.js @@ -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 ( +
+ +
+
{ + e.stopPropagation() + moveLeft() + }} + > + +
+
{ + e.stopPropagation() + moveRight() + }} + > + +
+
+
+ ) +} + +const CarouselContext = createContext() + +function CarouselOverflow ({ originalSrc, rel }) { + return view original +} + +export function CarouselProvider ({ children }) { + const media = useRef(new Map()) + const showModal = useShowModal() + + const showCarousel = useCallback(({ src }) => { + showModal((close, setOptions) => { + return + }, { + fullScreen: true, + overflow: + }) + }, [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 {children} +} + +export function useCarousel () { + return useContext(CarouselContext) +} diff --git a/components/carousel.module.css b/components/carousel.module.css new file mode 100644 index 00000000..505c999a --- /dev/null +++ b/components/carousel.module.css @@ -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; +} \ No newline at end of file diff --git a/components/item-full.js b/components/item-full.js index 5dab9dc3..a57b8883 100644 --- a/components/item-full.js +++ b/components/item-full.js @@ -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 }) { ) :
} - {item.parentId - ? - : ( -
{bio - ? - : } -
)} - {item.comments && -
- -
} + + {item.parentId + ? + : ( +
{bio + ? + : } +
)} + {item.comments && +
+ +
} +
) diff --git a/components/media-or-link.js b/components/media-or-link.js index 76ac52d7..e5ccc918 100644 --- a/components/media-or-link.js +++ b/components/media-or-link.js @@ -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 ( -
- -
- ) - }, { - fullScreen: true, - overflow: ( - - open original - ) - }), [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) diff --git a/components/modal.js b/components/modal.js index f785dd89..96aff77a 100644 --- a/components/modal.js +++ b/components/modal.js @@ -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 { diff --git a/components/text.js b/components/text.js index ac5c2851..741b1e1a 100644 --- a/components/text.js +++ b/components/text.js @@ -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('', '', styles.superscript) @@ -148,8 +149,9 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o return url } const srcSet = imgproxyUrls?.[url] + return - }, [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 (
- - {children} - + {carousel && tab !== 'preview' + ? ( + + {children} + ) + : ( + + + {children} + + )} {overflowing && !show &&