diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 00000000..8d72ef2a
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,3 @@
+.yalc/
+.yalcignore
+yalc.lock
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5b685159..ecc99300 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.1.2",
"license": "UNLICENSED",
"dependencies": {
+ "@jboolean/react-zoom-pan-pinch": "^3.7.2",
"@sentry/react": "^7.119.2",
"@sentry/tracing": "^7.114.0",
"@stripe/stripe-js": "^1.42.0",
@@ -114,6 +115,19 @@
"node": "^20"
}
},
+ ".yalc/react-zoom-pan-pinch": {
+ "version": "0.0.0",
+ "extraneous": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -2108,6 +2122,19 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true
},
+ "node_modules/@jboolean/react-zoom-pan-pinch": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/@jboolean/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.2.tgz",
+ "integrity": "sha512-eMMTjp5aRnuAe8n+Vu7BrSomHbY2RM+2L3XvMFlKIl/mKpMCuSJ/EXTFTUSkZj+U8Kiy1637ZCeAPUaXxAbHlQ==",
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-dom": "*"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
@@ -14781,6 +14808,12 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true
},
+ "@jboolean/react-zoom-pan-pinch": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/@jboolean/react-zoom-pan-pinch/-/react-zoom-pan-pinch-3.7.2.tgz",
+ "integrity": "sha512-eMMTjp5aRnuAe8n+Vu7BrSomHbY2RM+2L3XvMFlKIl/mKpMCuSJ/EXTFTUSkZj+U8Kiy1637ZCeAPUaXxAbHlQ==",
+ "requires": {}
+ },
"@jridgewell/gen-mapping": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index ba57db56..86ed6824 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,6 +17,7 @@
"author": "Julian Boilen",
"license": "UNLICENSED",
"dependencies": {
+ "@jboolean/react-zoom-pan-pinch": "^3.7.2",
"@sentry/react": "^7.119.2",
"@sentry/tracing": "^7.114.0",
"@stripe/stripe-js": "^1.42.0",
diff --git a/frontend/src/screens/App/screens/AnnouncementBanner/AnnouncementRegistry.tsx b/frontend/src/screens/App/screens/AnnouncementBanner/AnnouncementRegistry.tsx
index 61c85c39..a5087a34 100644
--- a/frontend/src/screens/App/screens/AnnouncementBanner/AnnouncementRegistry.tsx
+++ b/frontend/src/screens/App/screens/AnnouncementBanner/AnnouncementRegistry.tsx
@@ -2,6 +2,16 @@ import React from 'react';
import Announcment from './Announcement';
const ANNOUNCEMENTS_REGISTRY: Announcment[] = [
+ {
+ id: 'zoom',
+ expiresAt: new Date('2025-01-31'),
+ render: () => (
+
+ 🔍 What’s that sign say? Is that mom? New: zoom in and find
+ out.
+
+ ),
+ },
{
id: 'high-quality-imagery',
expiresAt: new Date('2024-09-05'),
diff --git a/frontend/src/screens/App/screens/AnnouncementBanner/index.tsx b/frontend/src/screens/App/screens/AnnouncementBanner/index.tsx
index 28c268a2..6a340cec 100644
--- a/frontend/src/screens/App/screens/AnnouncementBanner/index.tsx
+++ b/frontend/src/screens/App/screens/AnnouncementBanner/index.tsx
@@ -19,7 +19,7 @@ export default function AnnouncementBanner(): JSX.Element | null {
{announcementToDisplay.render()}{' '}
);
diff --git a/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less b/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less
index ea7a89ac..c7e5ebbd 100644
--- a/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less
+++ b/frontend/src/screens/App/screens/ViewerPane/ViewerPane.less
@@ -15,29 +15,14 @@
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
- .colorLayer {
- z-index: 1;
- position: absolute;
-
- user-select: none;
- pointer-events: none;
-
- // We get low res color images and "upscole" them here by only using the color information
- // and blending in the lightness of the full image
- mix-blend-mode: color;
- }
-
- .image,
- .colorLayer {
- object-fit: contain;
- object-position: center center;
+ .imageStack {
width: 100%;
height: 100%;
- transform: scale(1.05); // cuts off the borders
}
.overlay {
z-index: 2;
+ touch-action: none;
}
@content-padding: 5.5px;
@@ -48,7 +33,7 @@
grid-template-columns: 100%;
grid-template-areas:
'alternates'
- '.'
+ 'zoomHitArea'
'stories'
'buttons'
'credit';
@@ -84,6 +69,9 @@
border: none;
cursor: pointer;
+
+ // drop shadow
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
}
.alternates {
@@ -111,6 +99,8 @@
box-sizing: border-box;
color: white;
+
+ user-select: all;
}
.buttons {
@@ -146,4 +136,35 @@
// https://bugs.chromium.org/p/chromium/issues/detail?id=1194050
// Maybe revisit this later
}
+
+ .zoomHitArea {
+ grid-area: zoomHitArea;
+ }
+
+ // Class name to exclude from pinch zoom
+ .panPinchExcluded {
+ user-select: text;
+ }
+
+ .panning {
+ cursor: grabbing !important;
+ }
+
+ .transformWrapper,
+ .transformContent {
+ width: 100% !important;
+ height: 100% !important;
+ }
+
+ .zoomable {
+ cursor: zoom-in;
+
+ & > * {
+ cursor: auto;
+ }
+
+ &.zoomed {
+ cursor: zoom-out;
+ }
+ }
}
diff --git a/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.less b/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.less
index 42c7a71e..7a6678f3 100644
--- a/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.less
+++ b/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.less
@@ -12,6 +12,16 @@
.colorLayer {
// default state
.faded();
+
+ z-index: 1;
+ position: absolute;
+
+ user-select: none;
+ pointer-events: none;
+
+ // We get low res color images and "upscale" them here by only using the color information
+ // and blending in the lightness of the full image
+ mix-blend-mode: color;
}
.enter,
diff --git a/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.tsx b/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.tsx
index b0a14e00..0d67be26 100644
--- a/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.tsx
+++ b/frontend/src/screens/App/screens/ViewerPane/components/ColorLayer.tsx
@@ -12,7 +12,7 @@ export default function ColorLayer({
className,
}: {
photoIdentifier: string;
- className: string;
+ className?: string;
}): JSX.Element | null {
const {
colorEnabledForIdentifier,
@@ -49,7 +49,6 @@ export default function ColorLayer({
className={classNames(stylesheet.colorLayer, className)}
src={colorizedImageSrc}
onLoad={() => {
- console.log('loaded', enabled);
if (enabled) {
handleImageLoaded();
}
diff --git a/frontend/src/screens/App/screens/ViewerPane/components/ImageStack.less b/frontend/src/screens/App/screens/ViewerPane/components/ImageStack.less
new file mode 100644
index 00000000..16627212
--- /dev/null
+++ b/frontend/src/screens/App/screens/ViewerPane/components/ImageStack.less
@@ -0,0 +1,14 @@
+:local {
+ .image {
+ display: block;
+ object-fit: contain;
+ object-position: center center;
+ width: 100%;
+ height: 100%;
+ }
+
+ .overlayLayer {
+ z-index: 1;
+ position: absolute;
+ }
+}
diff --git a/frontend/src/screens/App/screens/ViewerPane/components/ImageStack.tsx b/frontend/src/screens/App/screens/ViewerPane/components/ImageStack.tsx
new file mode 100644
index 00000000..a4c4af19
--- /dev/null
+++ b/frontend/src/screens/App/screens/ViewerPane/components/ImageStack.tsx
@@ -0,0 +1,83 @@
+import React, { ImgHTMLAttributes } from 'react';
+
+import classNames from 'classnames';
+import { PHOTO_BASE } from 'shared/utils/apiConstants';
+import preloadImage from 'shared/utils/preloadImage';
+import ColorLayer from './ColorLayer';
+import stylesheet from './ImageStack.less';
+import { View } from './ImageSwitcher';
+
+type Props = {
+ photoIdentifier: string;
+ imgProps?: Omit, 'src'>;
+ className: string;
+ isFullResVisible?: boolean;
+};
+
+type ImageFormat = '720-jpg' | '420-jpg' | 'jpg';
+
+const forgeImgSrc = (
+ photoIdentifier: string,
+ format: ImageFormat = '720-jpg'
+): string => `${PHOTO_BASE}/${format}/${photoIdentifier}.jpg`;
+
+function HighResLayer({
+ photoIdentifier,
+}: {
+ photoIdentifier: string;
+}): JSX.Element {
+ const [loaded, setLoaded] = React.useState(false);
+
+ return (
+ setLoaded(true)}
+ style={{ opacity: loaded ? 1 : 0 }}
+ />
+ );
+}
+
+/**
+ * An ImageStack keeps all the layers for one photo aligned and combined so they can be treated as one.
+ * Layers: Base layer, high res layer, color layer.
+ */
+export default function ImageStack({
+ photoIdentifier,
+ imgProps,
+ className,
+ isFullResVisible,
+}: Props): JSX.Element | null {
+ const baseImageSrc = forgeImgSrc(photoIdentifier);
+
+ return (
+
+ {isFullResVisible && (
+
+ )}
+
+
+
+ );
+}
+export function makeImageSwitcherView(props: Props): View {
+ const imgSrc = forgeImgSrc(props.photoIdentifier);
+ return {
+ key: props.photoIdentifier,
+ element: ,
+ preload: () => preloadImage(imgSrc),
+ };
+}
diff --git a/frontend/src/screens/App/screens/ViewerPane/components/ImageSwitcher.tsx b/frontend/src/screens/App/screens/ViewerPane/components/ImageSwitcher.tsx
index 24fa6657..6cb7fe99 100644
--- a/frontend/src/screens/App/screens/ViewerPane/components/ImageSwitcher.tsx
+++ b/frontend/src/screens/App/screens/ViewerPane/components/ImageSwitcher.tsx
@@ -5,14 +5,21 @@ import recordEvent from 'shared/utils/recordEvent';
import Require from 'utils/Require';
+export type View = {
+ key: string;
+ element: React.ReactElement;
+ preload: () => Promise;
+};
interface State {
- lastSrc: string;
- visibleSrc: string;
+ lastView: View;
+ visibleView: View;
hide: boolean;
loaded: boolean;
}
-type Props = Require, 'src'>;
+type Props = {
+ view: View;
+};
import stylesheet from './ImageSwitcher.less';
@@ -23,12 +30,27 @@ function preloadImage(url: string, callback: () => void): void {
img.addEventListener('error', callback);
}
+// Make a view object for a basic img element
+export function makeImgView(
+ props: Require, 'src'>
+): View {
+ return {
+ key: props.src,
+ element: ,
+ preload: () => new Promise((resolve) => preloadImage(props.src, resolve)),
+ };
+}
+
+/**
+ * ImageSwitcher can transition between any Views that conform to the View interface.
+ * The goal is to increase perceived load speed by starting an animation immediately while preloading the view.
+ */
export default class ImageSwitcher extends React.Component {
constructor(props: Props) {
super(props);
this.state = {
- lastSrc: props.src,
- visibleSrc: props.src,
+ lastView: props.view,
+ visibleView: props.view,
hide: false,
loaded: true,
};
@@ -36,17 +58,24 @@ export default class ImageSwitcher extends React.Component {
this.handleExited = this.handleExited.bind(this);
}
- componentDidUpdate(prevProps: Props, prevState: State): void {
- if (this.props.src !== prevState.lastSrc && !this.state.hide) {
+ componentDidUpdate(_prevProps: Props, prevState: State): void {
+ if (this.props.view.key !== prevState.lastView.key && !this.state.hide) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState(
{
hide: true,
loaded: false,
- lastSrc: this.props.src,
+ lastView: this.props.view,
},
() => {
- preloadImage(this.props.src, this.handleNewImgLoad);
+ this.props.view.preload().then(
+ () => {
+ this.handleNewImgLoad();
+ },
+ (e) => {
+ console.error('Failed to preload image', e);
+ }
+ );
}
);
@@ -77,12 +106,18 @@ export default class ImageSwitcher extends React.Component {
swapImage(): void {
this.setState({
- visibleSrc: this.props.src,
+ visibleView: this.props.view,
});
}
render(): JSX.Element {
- const { visibleSrc, hide, loaded } = this.state;
+ const { visibleView, hide, loaded } = this.state;
+
+ // Use the one from props if it is the same key so we get the most up-to-date element
+ const element =
+ visibleView.key === this.props.view.key
+ ? this.props.view.element
+ : visibleView.element;
return (
{
timeout={150}
onExited={this.handleExited}
>
-
+ {element}
);
}
diff --git a/frontend/src/screens/App/screens/ViewerPane/components/Overlay/Overlay.less b/frontend/src/screens/App/screens/ViewerPane/components/Overlay/Overlay.less
index cc0d97e2..345480e0 100644
--- a/frontend/src/screens/App/screens/ViewerPane/components/Overlay/Overlay.less
+++ b/frontend/src/screens/App/screens/ViewerPane/components/Overlay/Overlay.less
@@ -11,5 +11,7 @@
height: 100%;
z-index: 2;
+ user-select: none;
+
.fadeTransition();
}
diff --git a/frontend/src/screens/App/screens/ViewerPane/components/Overlay/index.tsx b/frontend/src/screens/App/screens/ViewerPane/components/Overlay/index.tsx
index a545bb7c..a5833d46 100644
--- a/frontend/src/screens/App/screens/ViewerPane/components/Overlay/index.tsx
+++ b/frontend/src/screens/App/screens/ViewerPane/components/Overlay/index.tsx
@@ -1,20 +1,33 @@
-import React from 'react';
+import React, { RefObject } from 'react';
-import { CSSTransition } from 'react-transition-group';
import classnames from 'classnames';
+import { CSSTransition } from 'react-transition-group';
-import stylesheet from './Overlay.less';
import { useFeatureFlag } from 'screens/App/shared/stores/FeatureFlagsStore';
import FeatureFlag from 'screens/App/shared/types/FeatureFlag';
+import useCanHover from '../../shared/utils/useCanHover';
+import stylesheet from './Overlay.less';
// Encapsulates overlay logic for fading children in and out
export default function Overlay({
className,
+ overlayRef,
children,
-}: React.PropsWithChildren<{ className?: string }>): JSX.Element {
+}: React.PropsWithChildren<{
+ className?: string;
+ overlayRef?: RefObject;
+}>): JSX.Element {
// This feature flag is useful in development to prevent the overlay from disappearing
const alwaysShowOverlay = useFeatureFlag(FeatureFlag.ALWAYS_SHOW_OVERLAY);
+ // Okay so, my Android phone is reporting hover events of type "mouse"
+ // This will let us ignore those events on devices that cannot hover
+ const canHover = useCanHover();
+
+ const startXRef = React.useRef(0);
+ const startYRef = React.useRef(0);
+ const pointerIdRef = React.useRef(null);
+
const [isOverlayVisible, setIsOverlayVisible] =
React.useState(alwaysShowOverlay);
const timeoutRef = React.useRef(null);
@@ -39,10 +52,10 @@ export default function Overlay({
}
};
- const handleStartHover: React.PointerEventHandler = (
- e
- ): void => {
- if (e.pointerType !== 'mouse') {
+ const handleStartHover: React.PointerEventHandler<
+ HTMLDivElement
+ > = (): void => {
+ if (!canHover) {
return;
}
setIsOverlayVisible(true);
@@ -50,17 +63,41 @@ export default function Overlay({
};
const handlePointerDown: React.PointerEventHandler = (e) => {
- if (e.target !== e.currentTarget) {
+ pointerIdRef.current = e.pointerId;
+ startXRef.current = e.clientX;
+ startYRef.current = e.clientY;
+ };
+
+ const handlePointerUp: React.PointerEventHandler = (e) => {
+ if (e.pointerId !== pointerIdRef.current) {
+ return;
+ }
+ if (canHover) {
+ return;
+ }
+
+ // only count events on overlay or overlaycontent
+ if (
+ e.target !== overlayRef.current &&
+ e.target !== overlayRef.current?.firstChild
+ ) {
+ return;
+ }
+ const deltaX = e.clientX - startXRef.current;
+ const deltaY = e.clientY - startYRef.current;
+ const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
+ // If the pointer moved more than 10 pixels, ignore
+ if (distance > 10) {
return;
}
setIsOverlayVisible(!isOverlayVisible);
cancelPeekTimeout();
};
- const handleEndHover: React.PointerEventHandler = (
- e
- ): void => {
- if (e.pointerType !== 'mouse') {
+ const handleEndHover: React.PointerEventHandler<
+ HTMLDivElement
+ > = (): void => {
+ if (!canHover) {
return;
}
if (alwaysShowOverlay) {
@@ -75,6 +112,8 @@ export default function Overlay({
onPointerOver={handleStartHover}
onPointerLeave={handleEndHover}
onPointerDown={handlePointerDown}
+ onPointerUp={handlePointerUp}
+ ref={overlayRef}
>
-
- {children}
-
+ {children}
);
diff --git a/frontend/src/screens/App/screens/ViewerPane/index.tsx b/frontend/src/screens/App/screens/ViewerPane/index.tsx
index 295f3b90..72f810ed 100644
--- a/frontend/src/screens/App/screens/ViewerPane/index.tsx
+++ b/frontend/src/screens/App/screens/ViewerPane/index.tsx
@@ -1,19 +1,81 @@
-import React from 'react';
+import React, { useEffect } from 'react';
import classnames from 'classnames';
+import {
+ InnerTransformedContent,
+ ReactZoomPanPinchContentRef,
+ ReactZoomPanPinchProps,
+ TransformComponent,
+ TransformWrapper,
+} from '@jboolean/react-zoom-pan-pinch';
+import { useHistory, useParams } from 'react-router';
+import Alternates from './components/Alternates';
+import ImageButtons from './components/ImageButtons';
+import * as ImageStack from './components/ImageStack';
import ImageSwitcher from './components/ImageSwitcher';
+import Overlay from './components/Overlay';
+import Stories from './components/Stories';
import stylesheet from './ViewerPane.less';
+import useCanHover from './shared/utils/useCanHover';
-import { useHistory, useParams } from 'react-router';
-import Alternates from './components/Alternates';
-import Overlay from './components/Overlay';
+const INITIAL_SCALE = 1.05;
-import { PHOTO_BASE } from 'shared/utils/apiConstants';
-import ImageButtons from './components/ImageButtons';
-import Stories from './components/Stories';
-import ColorLayer from './components/ColorLayer';
+const ClickToZoomHitArea = ({
+ wrapper,
+ isZoomed,
+ isPanning,
+}: {
+ wrapper: ReactZoomPanPinchContentRef;
+ isZoomed: boolean;
+ isPanning: boolean;
+}): JSX.Element | null => {
+ const canHover = useCanHover();
+
+ const startXRef = React.useRef(0);
+ const startYRef = React.useRef(0);
+
+ const handleZoomHitAreaMouseDown = (
+ e: React.MouseEvent
+ ): void => {
+ startXRef.current = e.clientX;
+ startYRef.current = e.clientY;
+ };
+
+ const handleZoomHitAreaClick = (
+ e: React.MouseEvent
+ ): void => {
+ if (!wrapper) return;
+ const { clientX, clientY } = e;
+
+ const deltaX = clientX - startXRef.current;
+ const deltaY = clientY - startYRef.current;
+ const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
+ // If the pointer moved more than 10 pixels, ignore
+ if (distance > 10) {
+ return;
+ }
+
+ if (isZoomed) wrapper.resetTransform();
+ else wrapper.zoomToPoint(clientX, clientY, 3.8, 200, 'easeOut');
+ };
+
+ if (!canHover) {
+ return null;
+ }
+
+ return (
+
+ );
+};
export default function ViewerPane({
className,
@@ -23,47 +85,123 @@ export default function ViewerPane({
const { identifier: photoIdentifier } = useParams<{ identifier?: string }>();
const history = useHistory();
+ const overlayRef = React.useRef(null);
+ const wrapperRef = React.useRef(null);
+ const [isZoomed, setIsZoomed] = React.useState(false);
+ const [isPanning, setIsPanning] = React.useState(false);
+ const canHover = useCanHover();
+
+ // Reset the transform when the photo changes
+ useEffect(() => {
+ if (wrapperRef.current) wrapperRef.current.resetTransform();
+ }, [photoIdentifier, wrapperRef]);
+
+ const handleZoom: ReactZoomPanPinchProps['onTransformed'] = (
+ _ref,
+ state
+ ): void => {
+ const scale = state.scale;
+
+ setIsZoomed(scale > INITIAL_SCALE);
+ };
+
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Photo courtesy NYC Municipal Archives{' '}
-
-
-
-
-
-
+ setIsPanning(true)}
+ onPanningStop={() => setIsPanning(false)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Photo courtesy NYC Municipal Archives{' '}
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/frontend/src/screens/App/screens/ViewerPane/shared/utils/useCanHover.ts b/frontend/src/screens/App/screens/ViewerPane/shared/utils/useCanHover.ts
new file mode 100644
index 00000000..16dd65ba
--- /dev/null
+++ b/frontend/src/screens/App/screens/ViewerPane/shared/utils/useCanHover.ts
@@ -0,0 +1,27 @@
+import { useEffect, useState } from 'react';
+
+export default function useCanHover(): boolean {
+ const [hasFinePointer, setHasFinePointer] = useState(false);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia('(hover: hover)');
+
+ // Set initial value
+ setHasFinePointer(mediaQuery.matches);
+
+ // Define a handler for changes
+ const handleChange = (event: MediaQueryListEvent): void => {
+ setHasFinePointer(event.matches);
+ };
+
+ // Add event listener for changes
+ mediaQuery.addEventListener('change', handleChange);
+
+ // Cleanup event listener on unmount
+ return () => {
+ mediaQuery.removeEventListener('change', handleChange);
+ };
+ }, []);
+
+ return hasFinePointer;
+}
diff --git a/frontend/src/shared/components/Carousel/index.tsx b/frontend/src/shared/components/Carousel/index.tsx
index 67e64226..ec9bacd8 100644
--- a/frontend/src/shared/components/Carousel/index.tsx
+++ b/frontend/src/shared/components/Carousel/index.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { CSSTransition, SwitchTransition } from 'react-transition-group';
+import preloadImage from 'shared/utils/preloadImage';
import stylesheet from './Carousel.less';
interface Image {
@@ -28,6 +29,12 @@ export default function Carousel({
};
}, [images.length]);
+ // preload next image
+ React.useEffect(() => {
+ const nextI = (i + 1) % images.length;
+ void preloadImage(images[nextI].src);
+ }, [i, images]);
+
const image = images[i];
return (
diff --git a/frontend/src/shared/utils/preloadImage.ts b/frontend/src/shared/utils/preloadImage.ts
new file mode 100644
index 00000000..a07ffb65
--- /dev/null
+++ b/frontend/src/shared/utils/preloadImage.ts
@@ -0,0 +1,9 @@
+export default function preloadImage(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.src = url;
+
+ img.addEventListener('load', () => resolve());
+ img.addEventListener('error', reject);
+ });
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 356155d5..c6dfce67 100755
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -2,7 +2,8 @@
"compilerOptions": {
"outDir": "dist/src",
"noImplicitAny": true,
- "noUnusedLocals": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
"importHelpers": true,
"module": "commonjs",
"target": "es5",
diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js
index 6c4d6a43..18155227 100755
--- a/frontend/webpack.common.js
+++ b/frontend/webpack.common.js
@@ -27,20 +27,20 @@ module.exports = {
}),
new webpack.IgnorePlugin({ resourceRegExp: /^\.\/locale$/ }),
new CopyPlugin({
- patterns: [{ from: '_redirects' }, {from: 'terms.html'}],
+ patterns: [{ from: '_redirects' }, { from: 'terms.html' }],
}),
- new ESLintPlugin({ fix: true, }),
+ new ESLintPlugin({ fix: true, exclude: ['node_modules', '.yalc'] }),
],
module: {
rules: [
{
test: /\.jsx?$/,
- exclude: /node_modules/,
+ exclude: /node_modules|.yalc/,
use: ['babel-loader'],
},
{
test: /\.tsx?$/,
- exclude: /node_modules/,
+ exclude: /node_modules|.yalc/,
loader: 'ts-loader',
},
{
@@ -67,7 +67,7 @@ module.exports = {
{
test: /\.svg$/,
issuer: /\.(j|t)sx$/,
- exclude: /node_modules/,
+ exclude: /node_modules|.yalc/,
loader: 'svg-react-loader',
resourceQuery: { not: [/asset/] },
options: {