From f95168b3e473612b2f0c5735f58e0296e82a335b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 15:22:50 -0500 Subject: [PATCH 1/9] ZoomableImage: convert to TSX --- .../{zoomable_image.js => zoomable_image.tsx} | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) rename app/soapbox/features/ui/components/{zoomable_image.js => zoomable_image.tsx} (64%) diff --git a/app/soapbox/features/ui/components/zoomable_image.js b/app/soapbox/features/ui/components/zoomable_image.tsx similarity index 64% rename from app/soapbox/features/ui/components/zoomable_image.js rename to app/soapbox/features/ui/components/zoomable_image.tsx index b9064fa4f..e502d9e94 100644 --- a/app/soapbox/features/ui/components/zoomable_image.js +++ b/app/soapbox/features/ui/components/zoomable_image.tsx @@ -1,28 +1,28 @@ -import PropTypes from 'prop-types'; import React from 'react'; const MIN_SCALE = 1; const MAX_SCALE = 4; -const getMidpoint = (p1, p2) => ({ +type Point = { x: number, y: number }; +type EventRemover = () => void; + +const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({ x: (p1.clientX + p2.clientX) / 2, y: (p1.clientY + p2.clientY) / 2, }); -const getDistance = (p1, p2) => +const getDistance = (p1: React.Touch, p2: React.Touch): number => Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); -const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); +const clamp = (min: number, max: number, value: number): number => Math.min(max, Math.max(min, value)); -export default class ZoomableImage extends React.PureComponent { +interface IZoomableImage { + alt?: string, + src: string, + onClick?: React.MouseEventHandler, +} - static propTypes = { - alt: PropTypes.string, - src: PropTypes.string.isRequired, - width: PropTypes.number, - height: PropTypes.number, - onClick: PropTypes.func, - } +export default class ZoomableImage extends React.PureComponent { static defaultProps = { alt: '', @@ -34,21 +34,22 @@ export default class ZoomableImage extends React.PureComponent { scale: MIN_SCALE, } - removers = []; - container = null; - image = null; + removers: EventRemover[] = []; + container: HTMLDivElement | null = null; + image: HTMLImageElement | null = null; lastTouchEndTime = 0; lastDistance = 0; + lastMidpoint: Point | undefined = undefined; componentDidMount() { let handler = this.handleTouchStart; - this.container.addEventListener('touchstart', handler); - this.removers.push(() => this.container.removeEventListener('touchstart', handler)); + this.container?.addEventListener('touchstart', handler); + this.removers.push(() => this.container?.removeEventListener('touchstart', handler)); handler = this.handleTouchMove; // on Chrome 56+, touch event listeners will default to passive // https://www.chromestatus.com/features/5093566007214080 - this.container.addEventListener('touchmove', handler, { passive: false }); - this.removers.push(() => this.container.removeEventListener('touchend', handler)); + this.container?.addEventListener('touchmove', handler, { passive: false }); + this.removers.push(() => this.container?.removeEventListener('touchend', handler)); } componentWillUnmount() { @@ -60,13 +61,16 @@ export default class ZoomableImage extends React.PureComponent { this.removers = []; } - handleTouchStart = e => { + handleTouchStart = (e: TouchEvent) => { if (e.touches.length !== 2) return; + const [p1, p2] = Array.from(e.touches); - this.lastDistance = getDistance(...e.touches); + this.lastDistance = getDistance(p1, p2); } - handleTouchMove = e => { + handleTouchMove = (e: TouchEvent) => { + if (!this.container) return; + const { scrollTop, scrollHeight, clientHeight } = this.container; if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { // prevent propagating event to MediaModal @@ -78,8 +82,9 @@ export default class ZoomableImage extends React.PureComponent { e.preventDefault(); e.stopPropagation(); - const distance = getDistance(...e.touches); - const midpoint = getMidpoint(...e.touches); + const [p1, p2] = Array.from(e.touches); + const distance = getDistance(p1, p2); + const midpoint = getMidpoint(p1, p2); const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); this.zoom(scale, midpoint); @@ -88,7 +93,9 @@ export default class ZoomableImage extends React.PureComponent { this.lastDistance = distance; } - zoom(nextScale, midpoint) { + zoom(nextScale: number, midpoint: Point) { + if (!this.container) return; + const { scale } = this.state; const { scrollLeft, scrollTop } = this.container; @@ -102,23 +109,24 @@ export default class ZoomableImage extends React.PureComponent { const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; this.setState({ scale: nextScale }, () => { + if (!this.container) return; this.container.scrollLeft = nextScrollLeft; this.container.scrollTop = nextScrollTop; }); } - handleClick = e => { + handleClick: React.MouseEventHandler = e => { // don't propagate event to MediaModal e.stopPropagation(); const handler = this.props.onClick; - if (handler) handler(); + if (handler) handler(e); } - setContainerRef = c => { + setContainerRef = (c: HTMLDivElement) => { this.container = c; } - setImageRef = c => { + setImageRef = (c: HTMLImageElement) => { this.image = c; } From e6b0d17699c0b954f14d05f4e5265e9b0ab143b9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 15:26:34 -0500 Subject: [PATCH 2/9] ZoomableImage: refactor, clean up unused code --- .../features/ui/components/zoomable_image.tsx | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/app/soapbox/features/ui/components/zoomable_image.tsx b/app/soapbox/features/ui/components/zoomable_image.tsx index e502d9e94..84c66e1cb 100644 --- a/app/soapbox/features/ui/components/zoomable_image.tsx +++ b/app/soapbox/features/ui/components/zoomable_image.tsx @@ -4,7 +4,6 @@ const MIN_SCALE = 1; const MAX_SCALE = 4; type Point = { x: number, y: number }; -type EventRemover = () => void; const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({ x: (p1.clientX + p2.clientX) / 2, @@ -22,7 +21,7 @@ interface IZoomableImage { onClick?: React.MouseEventHandler, } -export default class ZoomableImage extends React.PureComponent { +class ZoomableImage extends React.PureComponent { static defaultProps = { alt: '', @@ -34,31 +33,20 @@ export default class ZoomableImage extends React.PureComponent { scale: MIN_SCALE, } - removers: EventRemover[] = []; container: HTMLDivElement | null = null; image: HTMLImageElement | null = null; - lastTouchEndTime = 0; lastDistance = 0; - lastMidpoint: Point | undefined = undefined; componentDidMount() { - let handler = this.handleTouchStart; - this.container?.addEventListener('touchstart', handler); - this.removers.push(() => this.container?.removeEventListener('touchstart', handler)); - handler = this.handleTouchMove; + this.container?.addEventListener('touchstart', this.handleTouchStart); // on Chrome 56+, touch event listeners will default to passive // https://www.chromestatus.com/features/5093566007214080 - this.container?.addEventListener('touchmove', handler, { passive: false }); - this.removers.push(() => this.container?.removeEventListener('touchend', handler)); + this.container?.addEventListener('touchmove', this.handleTouchMove, { passive: false }); } componentWillUnmount() { - this.removeEventListeners(); - } - - removeEventListeners() { - this.removers.forEach(listeners => listeners()); - this.removers = []; + this.container?.removeEventListener('touchstart', this.handleTouchStart); + this.container?.removeEventListener('touchend', this.handleTouchMove); } handleTouchStart = (e: TouchEvent) => { @@ -89,7 +77,6 @@ export default class ZoomableImage extends React.PureComponent { this.zoom(scale, midpoint); - this.lastMidpoint = midpoint; this.lastDistance = distance; } @@ -158,3 +145,5 @@ export default class ZoomableImage extends React.PureComponent { } } + +export default ZoomableImage; \ No newline at end of file From f42e8520b546c81368f8f0b62d7e0f3dcc1d3bc3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 15:27:26 -0500 Subject: [PATCH 3/9] zoomable_image --> zoomable-image --- app/soapbox/features/ui/components/image_loader.js | 2 +- .../ui/components/{zoomable_image.tsx => zoomable-image.tsx} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename app/soapbox/features/ui/components/{zoomable_image.tsx => zoomable-image.tsx} (100%) diff --git a/app/soapbox/features/ui/components/image_loader.js b/app/soapbox/features/ui/components/image_loader.js index 8e6e8ec55..32eb2a4f9 100644 --- a/app/soapbox/features/ui/components/image_loader.js +++ b/app/soapbox/features/ui/components/image_loader.js @@ -2,7 +2,7 @@ import classNames from 'clsx'; import PropTypes from 'prop-types'; import React from 'react'; -import ZoomableImage from './zoomable_image'; +import ZoomableImage from './zoomable-image'; export default class ImageLoader extends React.PureComponent { diff --git a/app/soapbox/features/ui/components/zoomable_image.tsx b/app/soapbox/features/ui/components/zoomable-image.tsx similarity index 100% rename from app/soapbox/features/ui/components/zoomable_image.tsx rename to app/soapbox/features/ui/components/zoomable-image.tsx From 5885c454af981f04da53822dc0e0c5dd6621a48d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 16:23:16 -0500 Subject: [PATCH 4/9] ImageLoader: convert to TSX --- .../{image_loader.js => image_loader.tsx} | 48 +++++++++++-------- 1 file changed, 27 insertions(+), 21 deletions(-) rename app/soapbox/features/ui/components/{image_loader.js => image_loader.tsx} (75%) diff --git a/app/soapbox/features/ui/components/image_loader.js b/app/soapbox/features/ui/components/image_loader.tsx similarity index 75% rename from app/soapbox/features/ui/components/image_loader.js rename to app/soapbox/features/ui/components/image_loader.tsx index 32eb2a4f9..ae60420ca 100644 --- a/app/soapbox/features/ui/components/image_loader.js +++ b/app/soapbox/features/ui/components/image_loader.tsx @@ -1,19 +1,20 @@ import classNames from 'clsx'; -import PropTypes from 'prop-types'; import React from 'react'; import ZoomableImage from './zoomable-image'; -export default class ImageLoader extends React.PureComponent { +type EventRemover = () => void; - static propTypes = { - alt: PropTypes.string, - src: PropTypes.string.isRequired, - previewSrc: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - onClick: PropTypes.func, - } +interface IImageLoader { + alt?: string, + src: string, + previewSrc?: string, + width?: number, + height?: number, + onClick?: React.MouseEventHandler, +} + +class ImageLoader extends React.PureComponent { static defaultProps = { alt: '', @@ -27,8 +28,9 @@ export default class ImageLoader extends React.PureComponent { width: null, } - removers = []; - canvas = null; + removers: EventRemover[] = []; + canvas: HTMLCanvasElement | null = null; + _canvasContext: CanvasRenderingContext2D | null = null; get canvasContext() { if (!this.canvas) { @@ -42,7 +44,7 @@ export default class ImageLoader extends React.PureComponent { this.loadImage(this.props); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: IImageLoader) { if (prevProps.src !== this.props.src) { this.loadImage(this.props); } @@ -52,7 +54,7 @@ export default class ImageLoader extends React.PureComponent { this.removeEventListeners(); } - loadImage(props) { + loadImage(props: IImageLoader) { this.removeEventListeners(); this.setState({ loading: true, error: false }); Promise.all([ @@ -66,7 +68,7 @@ export default class ImageLoader extends React.PureComponent { .catch(() => this.setState({ loading: false, error: true })); } - loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => { + loadPreviewCanvas = ({ previewSrc, width, height }: IImageLoader) => new Promise((resolve, reject) => { const image = new Image(); const removeEventListeners = () => { image.removeEventListener('error', handleError); @@ -78,21 +80,23 @@ export default class ImageLoader extends React.PureComponent { }; const handleLoad = () => { removeEventListeners(); - this.canvasContext.drawImage(image, 0, 0, width, height); + this.canvasContext?.drawImage(image, 0, 0, width || 0, height || 0); resolve(); }; image.addEventListener('error', handleError); image.addEventListener('load', handleLoad); - image.src = previewSrc; + image.src = previewSrc || ''; this.removers.push(removeEventListeners); }) clearPreviewCanvas() { - const { width, height } = this.canvas; - this.canvasContext.clearRect(0, 0, width, height); + if (this.canvas && this.canvasContext) { + const { width, height } = this.canvas; + this.canvasContext.clearRect(0, 0, width, height); + } } - loadOriginalImage = ({ src }) => new Promise((resolve, reject) => { + loadOriginalImage = ({ src }: IImageLoader) => new Promise((resolve, reject) => { const image = new Image(); const removeEventListeners = () => { image.removeEventListener('error', handleError); @@ -122,7 +126,7 @@ export default class ImageLoader extends React.PureComponent { return typeof width === 'number' && typeof height === 'number'; } - setCanvasRef = c => { + setCanvasRef = (c: HTMLCanvasElement) => { this.canvas = c; if (c) this.setState({ width: c.offsetWidth }); } @@ -157,3 +161,5 @@ export default class ImageLoader extends React.PureComponent { } } + +export default ImageLoader; \ No newline at end of file From 18b177d6c9bd43013dd2737e484223ee12cea398 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 16:26:19 -0500 Subject: [PATCH 5/9] image_loader --> image-loader --- .../ui/components/{image_loader.tsx => image-loader.tsx} | 0 app/soapbox/features/ui/components/media_modal.js | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename app/soapbox/features/ui/components/{image_loader.tsx => image-loader.tsx} (100%) diff --git a/app/soapbox/features/ui/components/image_loader.tsx b/app/soapbox/features/ui/components/image-loader.tsx similarity index 100% rename from app/soapbox/features/ui/components/image_loader.tsx rename to app/soapbox/features/ui/components/image-loader.tsx diff --git a/app/soapbox/features/ui/components/media_modal.js b/app/soapbox/features/ui/components/media_modal.js index dc988f270..61e7ff12d 100644 --- a/app/soapbox/features/ui/components/media_modal.js +++ b/app/soapbox/features/ui/components/media_modal.js @@ -13,7 +13,7 @@ import IconButton from 'soapbox/components/icon_button'; import Audio from 'soapbox/features/audio'; import Video from 'soapbox/features/video'; -import ImageLoader from './image_loader'; +import ImageLoader from './image-loader'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, From 80ce70e33ea7ed2fa6c9298bd613a4b139699d89 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 12 Oct 2022 17:16:37 -0500 Subject: [PATCH 6/9] MediaModal: convert to TSX+FC --- .../features/ui/components/media_modal.js | 274 ---------------- .../features/ui/components/media_modal.tsx | 300 ++++++++++++++++++ 2 files changed, 300 insertions(+), 274 deletions(-) delete mode 100644 app/soapbox/features/ui/components/media_modal.js create mode 100644 app/soapbox/features/ui/components/media_modal.tsx diff --git a/app/soapbox/features/ui/components/media_modal.js b/app/soapbox/features/ui/components/media_modal.js deleted file mode 100644 index 61e7ff12d..000000000 --- a/app/soapbox/features/ui/components/media_modal.js +++ /dev/null @@ -1,274 +0,0 @@ -import classNames from 'clsx'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { withRouter } from 'react-router-dom'; -import ReactSwipeableViews from 'react-swipeable-views'; - -import ExtendedVideoPlayer from 'soapbox/components/extended_video_player'; -import Icon from 'soapbox/components/icon'; -import IconButton from 'soapbox/components/icon_button'; -import Audio from 'soapbox/features/audio'; -import Video from 'soapbox/features/video'; - -import ImageLoader from './image-loader'; - -const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, - previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, - next: { id: 'lightbox.next', defaultMessage: 'Next' }, -}); - -export default @injectIntl @withRouter -class MediaModal extends ImmutablePureComponent { - - static propTypes = { - media: ImmutablePropTypes.list.isRequired, - status: ImmutablePropTypes.record, - account: ImmutablePropTypes.record, - index: PropTypes.number.isRequired, - onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - history: PropTypes.object, - }; - - state = { - index: null, - navigationHidden: false, - }; - - handleSwipe = (index) => { - this.setState({ index: index % this.props.media.size }); - } - - handleNextClick = () => { - this.setState({ index: (this.getIndex() + 1) % this.props.media.size }); - } - - handlePrevClick = () => { - this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); - } - - handleChangeIndex = (e) => { - const index = Number(e.currentTarget.getAttribute('data-index')); - this.setState({ index: index % this.props.media.size }); - } - - handleKeyDown = (e) => { - switch (e.key) { - case 'ArrowLeft': - this.handlePrevClick(); - e.preventDefault(); - e.stopPropagation(); - break; - case 'ArrowRight': - this.handleNextClick(); - e.preventDefault(); - e.stopPropagation(); - break; - } - } - - componentDidMount() { - window.addEventListener('keydown', this.handleKeyDown, false); - } - - componentWillUnmount() { - window.removeEventListener('keydown', this.handleKeyDown); - } - - getIndex() { - return this.state.index !== null ? this.state.index : this.props.index; - } - - toggleNavigation = () => { - this.setState(prevState => ({ - navigationHidden: !prevState.navigationHidden, - })); - }; - - handleStatusClick = e => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - const { status, account } = this.props; - const acct = account.get('acct'); - const statusId = status.get('id'); - this.props.history.push(`/@${acct}/posts/${statusId}`); - this.props.onClose(null, true); - } - } - - handleCloserClick = ({ target }) => { - const whitelist = ['zoomable-image']; - const activeSlide = document.querySelector('.media-modal .react-swipeable-view-container > div[aria-hidden="false"]'); - - const isClickOutside = target === activeSlide || !activeSlide.contains(target); - const isWhitelisted = whitelist.some(w => target.classList.contains(w)); - - if (isClickOutside || isWhitelisted) { - this.props.onClose(); - } - } - - render() { - const { media, status, account, intl, onClose } = this.props; - const { navigationHidden } = this.state; - - const index = this.getIndex(); - let pagination = []; - - const leftNav = media.size > 1 && ( - - ); - - const rightNav = media.size > 1 && ( - - ); - - if (media.size > 1) { - pagination = media.map((item, i) => { - const classes = ['media-modal__button']; - if (i === index) { - classes.push('media-modal__button--active'); - } - return (
  • ); - }); - } - - const isMultiMedia = media.map((image) => { - if (image.get('type') !== 'image') { - return true; - } - - return false; - }).toArray(); - - const content = media.map(attachment => { - const width = attachment.getIn(['meta', 'original', 'width']) || null; - const height = attachment.getIn(['meta', 'original', 'height']) || null; - const link = (status && account && ); - - if (attachment.get('type') === 'image') { - return ( - - ); - } else if (attachment.get('type') === 'video') { - const { time } = this.props; - - return ( -