pl-fe: port ZoomableImage from upstream Mastodon
Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
@ -72,6 +72,7 @@
|
|||||||
"@transfem-org/sfm-js": "^0.24.6",
|
"@transfem-org/sfm-js": "^0.24.6",
|
||||||
"@twemoji/svg": "^15.0.0",
|
"@twemoji/svg": "^15.0.0",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
|
"@use-gesture/react": "^10.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@yornaath/batshit": "^0.11.1",
|
"@yornaath/batshit": "^0.11.1",
|
||||||
"abortcontroller-polyfill": "^1.7.8",
|
"abortcontroller-polyfill": "^1.7.8",
|
||||||
|
|||||||
@ -11,7 +11,7 @@ interface IImageLoader {
|
|||||||
previewSrc?: string;
|
previewSrc?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
onClick?: React.MouseEventHandler;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageLoader extends React.PureComponent<IImageLoader> {
|
class ImageLoader extends React.PureComponent<IImageLoader> {
|
||||||
|
|||||||
@ -1,150 +1,324 @@
|
|||||||
import React from 'react';
|
import { useSpring, animated, config, to } from '@react-spring/web';
|
||||||
|
import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
import Blurhash from 'pl-fe/components/blurhash';
|
||||||
|
import Spinner from 'pl-fe/components/ui/spinner';
|
||||||
|
|
||||||
const MIN_SCALE = 1;
|
const MIN_SCALE = 1;
|
||||||
const MAX_SCALE = 4;
|
const MAX_SCALE = 4;
|
||||||
|
const DOUBLE_CLICK_THRESHOLD = 250;
|
||||||
|
|
||||||
type Point = { x: number; y: number };
|
interface ZoomMatrix {
|
||||||
|
containerWidth: number;
|
||||||
|
containerHeight: number;
|
||||||
|
imageWidth: number;
|
||||||
|
imageHeight: number;
|
||||||
|
initialScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
const getMidpoint = (p1: React.Touch, p2: React.Touch): Point => ({
|
const createZoomMatrix = (
|
||||||
x: (p1.clientX + p2.clientX) / 2,
|
container: HTMLElement,
|
||||||
y: (p1.clientY + p2.clientY) / 2,
|
image: HTMLImageElement,
|
||||||
});
|
fullWidth: number,
|
||||||
|
fullHeight: number,
|
||||||
|
): ZoomMatrix => {
|
||||||
|
const { clientWidth, clientHeight } = container;
|
||||||
|
const { offsetWidth, offsetHeight } = image;
|
||||||
|
|
||||||
const getDistance = (p1: React.Touch, p2: React.Touch): number =>
|
const type =
|
||||||
Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
|
fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
|
||||||
|
|
||||||
const clamp = (min: number, max: number, value: number): number => Math.min(max, Math.max(min, value));
|
const initialScale =
|
||||||
|
type === 'width'
|
||||||
|
? Math.min(clientWidth, fullWidth) / offsetWidth
|
||||||
|
: Math.min(clientHeight, fullHeight) / offsetHeight;
|
||||||
|
|
||||||
|
return {
|
||||||
|
containerWidth: clientWidth,
|
||||||
|
containerHeight: clientHeight,
|
||||||
|
imageWidth: offsetWidth,
|
||||||
|
imageHeight: offsetHeight,
|
||||||
|
initialScale,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const useGesture = createUseGesture([dragAction, pinchAction]);
|
||||||
|
|
||||||
|
const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => {
|
||||||
|
if (!zoomMatrix || scale === MIN_SCALE) {
|
||||||
|
return {
|
||||||
|
left: -Infinity,
|
||||||
|
right: Infinity,
|
||||||
|
top: -Infinity,
|
||||||
|
bottom: Infinity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { containerWidth, containerHeight, imageWidth, imageHeight } =
|
||||||
|
zoomMatrix;
|
||||||
|
|
||||||
|
const bounds = {
|
||||||
|
left: -Math.max(imageWidth * scale - containerWidth, 0) / 2,
|
||||||
|
right: Math.max(imageWidth * scale - containerWidth, 0) / 2,
|
||||||
|
top: -Math.max(imageHeight * scale - containerHeight, 0) / 2,
|
||||||
|
bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
};
|
||||||
|
|
||||||
interface IZoomableImage {
|
interface IZoomableImage {
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
lang?: string;
|
||||||
src: string;
|
src: string;
|
||||||
onClick?: React.MouseEventHandler;
|
width: number;
|
||||||
|
height: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onZoomChange?: (zoomedIn: boolean) => void;
|
||||||
|
zoomedIn?: boolean;
|
||||||
|
blurhash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ZoomableImage extends React.PureComponent<IZoomableImage> {
|
const ZoomableImage: React.FC<IZoomableImage> = ({
|
||||||
|
alt = '',
|
||||||
static defaultProps = {
|
lang = '',
|
||||||
alt: '',
|
src,
|
||||||
width: null,
|
width,
|
||||||
height: null,
|
height,
|
||||||
|
onClick,
|
||||||
|
onDoubleClick,
|
||||||
|
onClose,
|
||||||
|
onZoomChange,
|
||||||
|
zoomedIn,
|
||||||
|
blurhash,
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
document.addEventListener('gesturestart', handler);
|
||||||
scale: MIN_SCALE,
|
document.addEventListener('gesturechange', handler);
|
||||||
|
document.addEventListener('gestureend', handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('gesturestart', handler);
|
||||||
|
document.removeEventListener('gesturechange', handler);
|
||||||
|
document.removeEventListener('gestureend', handler);
|
||||||
};
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
container: HTMLDivElement | null = null;
|
const [dragging, setDragging] = useState(false);
|
||||||
image: HTMLImageElement | null = null;
|
const [loaded, setLoaded] = useState(false);
|
||||||
lastDistance = 0;
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
componentDidMount() {
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
this.container?.addEventListener('touchstart', this.handleTouchStart);
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
// on Chrome 56+, touch event listeners will default to passive
|
const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||||
// https://www.chromestatus.com/features/5093566007214080
|
const zoomMatrixRef = useRef<ZoomMatrix | null>(null);
|
||||||
this.container?.addEventListener('touchmove', this.handleTouchMove, { passive: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
const [style, api] = useSpring(() => ({
|
||||||
this.container?.removeEventListener('touchstart', this.handleTouchStart);
|
x: 0,
|
||||||
this.container?.removeEventListener('touchend', this.handleTouchMove);
|
y: 0,
|
||||||
}
|
scale: 1,
|
||||||
|
onRest: {
|
||||||
handleTouchStart = (e: TouchEvent) => {
|
scale({ value }) {
|
||||||
if (e.touches.length !== 2) return;
|
if (!onZoomChange) {
|
||||||
const [p1, p2] = Array.from(e.touches);
|
|
||||||
|
|
||||||
this.lastDistance = getDistance(p1, p2);
|
|
||||||
};
|
|
||||||
|
|
||||||
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
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.touches.length !== 2) return;
|
if (value === MIN_SCALE) {
|
||||||
|
onZoomChange(false);
|
||||||
|
} else {
|
||||||
|
onZoomChange(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useGesture(
|
||||||
|
{
|
||||||
|
onDrag({
|
||||||
|
pinching,
|
||||||
|
cancel,
|
||||||
|
active,
|
||||||
|
last,
|
||||||
|
offset: [x, y],
|
||||||
|
velocity: [, vy],
|
||||||
|
direction: [, dy],
|
||||||
|
tap,
|
||||||
|
}) {
|
||||||
|
if (tap) {
|
||||||
|
if (!doubleClickTimeoutRef.current) {
|
||||||
|
doubleClickTimeoutRef.current = setTimeout(() => {
|
||||||
|
onClick?.();
|
||||||
|
doubleClickTimeoutRef.current = null;
|
||||||
|
}, DOUBLE_CLICK_THRESHOLD);
|
||||||
|
} else {
|
||||||
|
clearTimeout(doubleClickTimeoutRef.current);
|
||||||
|
doubleClickTimeoutRef.current = null;
|
||||||
|
onDoubleClick?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zoomedIn) {
|
||||||
|
// Swipe up/down to dismiss parent
|
||||||
|
if (last) {
|
||||||
|
if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
void api.start({ y: 0, config: config.wobbly });
|
||||||
|
return;
|
||||||
|
} else if (dy !== 0) {
|
||||||
|
void api.start({ y, immediate: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinching) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setDragging(true);
|
||||||
|
} else {
|
||||||
|
setDragging(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void api.start({ x, y });
|
||||||
|
},
|
||||||
|
|
||||||
|
onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
|
||||||
|
if (!imageRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
const { width, height, x, y } =
|
||||||
|
imageRef.current.getBoundingClientRect();
|
||||||
|
const tx = ox - (x + width / 2);
|
||||||
|
const ty = oy - (y + height / 2);
|
||||||
|
|
||||||
|
memo = [style.x.get(), style.y.get(), tx, ty];
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
|
||||||
|
|
||||||
|
void api.start({ scale: s, x, y });
|
||||||
|
|
||||||
|
return memo as [number, number, number, number];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: imageRef,
|
||||||
|
drag: {
|
||||||
|
from: () => [style.x.get(), style.y.get()],
|
||||||
|
filterTaps: true,
|
||||||
|
bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()),
|
||||||
|
rubberband: true,
|
||||||
|
},
|
||||||
|
pinch: {
|
||||||
|
scaleBounds: {
|
||||||
|
min: MIN_SCALE,
|
||||||
|
max: MAX_SCALE,
|
||||||
|
},
|
||||||
|
rubberband: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded || !containerRef.current || !imageRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomMatrixRef.current = createZoomMatrix(
|
||||||
|
containerRef.current,
|
||||||
|
imageRef.current,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!zoomedIn) {
|
||||||
|
void api.start({ scale: MIN_SCALE, x: 0, y: 0 });
|
||||||
|
} else if (style.scale.get() === MIN_SCALE) {
|
||||||
|
void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
}, [api, style.scale, zoomedIn, width, height, loaded]);
|
||||||
|
|
||||||
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
// This handler exists to cancel the onClick handler on the media modal which would
|
||||||
|
// otherwise close the modal. It cannot be used for actual click handling because
|
||||||
|
// we don't know if the user is about to pan the image or not.
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [p1, p2] = Array.from(e.touches);
|
const handleLoad = useCallback(() => {
|
||||||
const distance = getDistance(p1, p2);
|
setLoaded(true);
|
||||||
const midpoint = getMidpoint(p1, p2);
|
}, [setLoaded]);
|
||||||
const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
|
|
||||||
|
|
||||||
this.zoom(scale, midpoint);
|
const handleError = useCallback(() => {
|
||||||
|
setError(true);
|
||||||
|
}, [setError]);
|
||||||
|
|
||||||
this.lastDistance = distance;
|
// Convert the default style transform to a matrix transform to work around
|
||||||
};
|
// Safari bug https://github.com/mastodon/mastodon/issues/35042
|
||||||
|
const transform = to(
|
||||||
zoom(nextScale: number, midpoint: Point) {
|
[style.scale, style.x, style.y],
|
||||||
if (!this.container) return;
|
(s, x, y) => `matrix(${s}, 0, 0, ${s}, ${x}, ${y})`,
|
||||||
|
);
|
||||||
const { scale } = this.state;
|
|
||||||
const { scrollLeft, scrollTop } = this.container;
|
|
||||||
|
|
||||||
// math memo:
|
|
||||||
// x = (scrollLeft + midpoint.x) / scrollWidth
|
|
||||||
// x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
|
|
||||||
// scrollWidth = clientWidth * scale
|
|
||||||
// scrollWidth' = clientWidth * nextScale
|
|
||||||
// Solve x = x' for nextScrollLeft
|
|
||||||
const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
|
|
||||||
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: React.MouseEventHandler = e => {
|
|
||||||
// don't propagate event to MediaModal
|
|
||||||
e.stopPropagation();
|
|
||||||
const handler = this.props.onClick;
|
|
||||||
if (handler) handler(e);
|
|
||||||
};
|
|
||||||
|
|
||||||
setContainerRef = (c: HTMLDivElement) => {
|
|
||||||
this.container = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setImageRef = (c: HTMLImageElement) => {
|
|
||||||
this.image = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { alt, src } = this.props;
|
|
||||||
const { scale } = this.state;
|
|
||||||
const overflow = scale === 1 ? 'hidden' : 'scroll';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='relative flex size-full items-center justify-center'
|
className={clsx('⁂-zoomable-image', {
|
||||||
ref={this.setContainerRef}
|
'⁂-zoomable-image--zoomed-in': zoomedIn,
|
||||||
style={{ overflow }}
|
'⁂-zoomable-image--error': error,
|
||||||
|
'⁂-zoomable-image--dragging': dragging,
|
||||||
|
})}
|
||||||
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<img
|
{!loaded && blurhash && (
|
||||||
className='size-auto max-h-[80%] max-w-full object-contain shadow-2xl'
|
<div
|
||||||
role='presentation'
|
className='⁂-zoomable-image__preview'
|
||||||
ref={this.setImageRef}
|
|
||||||
alt={alt}
|
|
||||||
title={alt}
|
|
||||||
src={src}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `scale(${scale})`,
|
aspectRatio: `${width}/${height}`,
|
||||||
transformOrigin: '0 0',
|
height: `min(${height}px, 100%)`,
|
||||||
}}
|
}}
|
||||||
onClick={this.handleClick}
|
>
|
||||||
|
<Blurhash hash={blurhash} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<animated.img
|
||||||
|
style={{ transform }}
|
||||||
|
ref={imageRef}
|
||||||
|
alt={alt}
|
||||||
|
lang={lang}
|
||||||
|
src={src}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
draggable={false}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onError={handleError}
|
||||||
|
onClickCapture={handleClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!loaded && !error && <Spinner />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ZoomableImage as default };
|
export { ZoomableImage as default };
|
||||||
|
|||||||
@ -5,3 +5,31 @@
|
|||||||
.⁂-hotkey-modal {
|
.⁂-hotkey-modal {
|
||||||
@apply max-w-4xl;
|
@apply max-w-4xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.⁂-zoomable-image {
|
||||||
|
@apply relative flex size-full items-center justify-center;
|
||||||
|
|
||||||
|
&--zoomed-in {
|
||||||
|
@apply cursor-grab z-[9999];
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error img {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--dragging {
|
||||||
|
@apply cursor-grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
@apply size-auto max-h-[80%] max-w-full absolute z-[1] overflow-hidden;
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
@apply absolute size-full object-cover z-[-1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
@apply size-auto max-h-[80%] max-w-full object-contain shadow-2xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -217,6 +217,9 @@ importers:
|
|||||||
'@uidotdev/usehooks':
|
'@uidotdev/usehooks':
|
||||||
specifier: ^2.4.1
|
specifier: ^2.4.1
|
||||||
version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@use-gesture/react':
|
||||||
|
specifier: ^10.3.1
|
||||||
|
version: 10.3.1(react@18.3.1)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.7.0(vite@5.4.21(@types/node@22.17.0)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.0))
|
version: 4.7.0(vite@5.4.21(@types/node@22.17.0)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.0))
|
||||||
@ -2874,6 +2877,14 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@use-gesture/core@10.3.1':
|
||||||
|
resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==}
|
||||||
|
|
||||||
|
'@use-gesture/react@10.3.1':
|
||||||
|
resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>= 16.8.0'
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0':
|
'@vitejs/plugin-react@4.7.0':
|
||||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
@ -9548,6 +9559,13 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@use-gesture/core@10.3.1': {}
|
||||||
|
|
||||||
|
'@use-gesture/react@10.3.1(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@use-gesture/core': 10.3.1
|
||||||
|
react: 18.3.1
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.17.0)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.0))':
|
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.17.0)(sass-embedded@1.93.3)(sass@1.93.3)(terser@5.44.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.0
|
'@babel/core': 7.28.0
|
||||||
|
|||||||
Reference in New Issue
Block a user