* Support Markdown svg and custom size inline images * remove commonmark patches * move getMarkdownImageSize to @utils/markdown * Fix worklet not present while running unit tests that use calculateDimensions * Set max size for SVG * Set svg dimensions based on calculated size * feedback review
608 lines
20 KiB
TypeScript
608 lines
20 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
|
import {StyleSheet} from 'react-native';
|
|
import FastImage, {Source} from 'react-native-fast-image';
|
|
import {
|
|
PanGestureHandler, PanGestureHandlerGestureEvent, PinchGestureHandler, PinchGestureHandlerGestureEvent,
|
|
State, TapGestureHandler, TapGestureHandlerGestureEvent,
|
|
} from 'react-native-gesture-handler';
|
|
import Animated, {
|
|
cancelAnimation, Easing, useAnimatedReaction, useAnimatedStyle, useDerivedValue,
|
|
useSharedValue,
|
|
withDecay, withSpring, WithSpringConfig, withTiming, WithTimingConfig,
|
|
} from 'react-native-reanimated';
|
|
import {SvgUri} from 'react-native-svg';
|
|
|
|
import {useAnimatedGestureHandler} from '@hooks/gallery';
|
|
import {clamp, workletNoop} from '@utils/gallery';
|
|
import * as vec from '@utils/gallery/vectors';
|
|
import {calculateDimensions} from '@utils/images';
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
width: '100%',
|
|
},
|
|
wrapper: {
|
|
...StyleSheet.absoluteFillObject,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
svg: {
|
|
backgroundColor: '#FFF',
|
|
borderRadius: 8,
|
|
},
|
|
});
|
|
|
|
const springConfig: WithSpringConfig = {
|
|
stiffness: 1000,
|
|
damping: 500,
|
|
mass: 3,
|
|
overshootClamping: true,
|
|
restDisplacementThreshold: 0.01,
|
|
restSpeedThreshold: 0.01,
|
|
};
|
|
|
|
const timingConfig: WithTimingConfig = {
|
|
duration: 250,
|
|
|
|
// @ts-expect-error EasingFactoryFunction missing in type definition
|
|
easing: Easing.bezier(0.33, 0.01, 0, 1),
|
|
};
|
|
|
|
export interface RenderImageProps {
|
|
width: number;
|
|
height: number;
|
|
source: Source;
|
|
onLoad: () => void;
|
|
}
|
|
|
|
export type InteractionType = 'scale' | 'pan';
|
|
|
|
export interface ImageTransformerReusableProps {
|
|
onDoubleTap?: (isScaled: boolean) => void;
|
|
onInteraction?: (type: InteractionType) => void;
|
|
onTap?: (isScaled: boolean) => void;
|
|
}
|
|
|
|
export interface ImageTransformerProps extends ImageTransformerReusableProps {
|
|
enabled?: boolean;
|
|
height: number;
|
|
isActive?: Animated.SharedValue<boolean>;
|
|
isSvg: boolean;
|
|
onStateChange?: (isActive: boolean) => void;
|
|
outerGestureHandlerActive?: Animated.SharedValue<boolean>;
|
|
outerGestureHandlerRefs?: Array<React.Ref<unknown>>;
|
|
source: Source | string;
|
|
targetDimensions: { width: number; height: number };
|
|
width: number;
|
|
}
|
|
|
|
const DOUBLE_TAP_SCALE = 3;
|
|
const MAX_SCALE = 3;
|
|
const MIN_SCALE = 0.7;
|
|
const OVER_SCALE = 0.5;
|
|
|
|
function checkIsNotUsed(handlerState: Animated.SharedValue<State>) {
|
|
'worklet';
|
|
|
|
return (
|
|
handlerState.value !== State.UNDETERMINED &&
|
|
handlerState.value !== State.END
|
|
);
|
|
}
|
|
|
|
const ImageTransformer = (
|
|
{
|
|
enabled = true, height, isActive,
|
|
onDoubleTap = workletNoop, onInteraction = workletNoop, onStateChange = workletNoop, onTap = workletNoop,
|
|
outerGestureHandlerActive, outerGestureHandlerRefs = [], source, isSvg,
|
|
targetDimensions, width,
|
|
}: ImageTransformerProps) => {
|
|
const imageSource = typeof source === 'string' ? {uri: source} : source;
|
|
const [pinchEnabled, setPinchEnabledState] = useState(true);
|
|
const interactionsEnabled = useSharedValue(false);
|
|
|
|
const setInteractionsEnabled = useCallback((value: boolean) => {
|
|
interactionsEnabled.value = value;
|
|
}, []);
|
|
|
|
const onLoadImageSuccess = useCallback(() => {
|
|
setInteractionsEnabled(true);
|
|
}, []);
|
|
|
|
const disablePinch = useCallback(() => {
|
|
setPinchEnabledState(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!pinchEnabled) {
|
|
setPinchEnabledState(true);
|
|
}
|
|
}, [pinchEnabled]);
|
|
|
|
const pinchRef = useRef(null);
|
|
const panRef = useRef(null);
|
|
const tapRef = useRef(null);
|
|
const doubleTapRef = useRef(null);
|
|
|
|
const panState = useSharedValue<State>(State.UNDETERMINED);
|
|
const pinchState = useSharedValue<State>(State.UNDETERMINED);
|
|
|
|
const scale = useSharedValue(1);
|
|
const scaleOffset = useSharedValue(1);
|
|
const translation = vec.useSharedVector(0, 0);
|
|
const panVelocity = vec.useSharedVector(0, 0);
|
|
const scaleTranslation = vec.useSharedVector(0, 0);
|
|
const offset = vec.useSharedVector(0, 0);
|
|
|
|
const canvas = vec.create(targetDimensions.width, targetDimensions.height);
|
|
|
|
const {width: targetWidth, height: targetHeight} = calculateDimensions(
|
|
height,
|
|
width,
|
|
targetDimensions.width,
|
|
targetDimensions.height,
|
|
);
|
|
|
|
const image = vec.create(targetWidth, targetHeight);
|
|
|
|
const canPanVertically = useDerivedValue(() => {
|
|
return targetDimensions.height < targetHeight * scale.value;
|
|
}, []);
|
|
|
|
function resetSharedState(animated?: boolean) {
|
|
'worklet';
|
|
|
|
if (animated) {
|
|
scale.value = withTiming(1, timingConfig);
|
|
scaleOffset.value = 1;
|
|
|
|
vec.set(offset, () => withTiming(0, timingConfig));
|
|
} else {
|
|
scale.value = 1;
|
|
scaleOffset.value = 1;
|
|
vec.set(translation, 0);
|
|
vec.set(scaleTranslation, 0);
|
|
vec.set(offset, 0);
|
|
}
|
|
}
|
|
|
|
const maybeRunOnEnd = () => {
|
|
'worklet';
|
|
|
|
const target = vec.create(0, 0);
|
|
|
|
const fixedScale = clamp(MIN_SCALE, scale.value, MAX_SCALE);
|
|
const scaledImage = image.y * fixedScale;
|
|
const rightBoundary = (canvas.x / 2) * (fixedScale - 1);
|
|
|
|
let topBoundary = 0;
|
|
|
|
if (canvas.y < scaledImage) {
|
|
topBoundary = Math.abs(scaledImage - canvas.y) / 2;
|
|
}
|
|
|
|
const maxVector = vec.create(rightBoundary, topBoundary);
|
|
const minVector = vec.invert(maxVector);
|
|
|
|
if (!canPanVertically.value) {
|
|
offset.y.value = withSpring(target.y, springConfig);
|
|
}
|
|
|
|
// we should handle this only if pan or pinch handlers has been used already
|
|
if (
|
|
(checkIsNotUsed(panState) || checkIsNotUsed(pinchState)) &&
|
|
pinchState.value !== State.CANCELLED
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
vec.eq(offset, 0) &&
|
|
vec.eq(translation, 0) &&
|
|
vec.eq(scaleTranslation, 0) &&
|
|
scale.value === 1
|
|
) {
|
|
// we don't need to run any animations
|
|
return;
|
|
}
|
|
|
|
if (scale.value <= 1) {
|
|
// just center it
|
|
vec.set(offset, () => withTiming(0, timingConfig));
|
|
return;
|
|
}
|
|
|
|
vec.set(target, vec.clamp(offset, minVector, maxVector));
|
|
|
|
const deceleration = 0.9915;
|
|
|
|
const isInBoundaryX = target.x === offset.x.value;
|
|
const isInBoundaryY = target.y === offset.y.value;
|
|
|
|
if (isInBoundaryX) {
|
|
if (
|
|
Math.abs(panVelocity.x.value) > 0 &&
|
|
scale.value <= MAX_SCALE
|
|
) {
|
|
offset.x.value = withDecay({
|
|
velocity: panVelocity.x.value,
|
|
clamp: [minVector.x, maxVector.x],
|
|
deceleration,
|
|
});
|
|
}
|
|
} else {
|
|
offset.x.value = withSpring(target.x, springConfig);
|
|
}
|
|
|
|
if (isInBoundaryY) {
|
|
if (
|
|
Math.abs(panVelocity.y.value) > 0 &&
|
|
scale.value <= MAX_SCALE &&
|
|
offset.y.value !== minVector.y &&
|
|
offset.y.value !== maxVector.y
|
|
) {
|
|
offset.y.value = withDecay({
|
|
velocity: panVelocity.y.value,
|
|
clamp: [minVector.y, maxVector.y],
|
|
deceleration,
|
|
});
|
|
}
|
|
} else {
|
|
offset.y.value = withSpring(target.y, springConfig);
|
|
}
|
|
};
|
|
|
|
const onPanEvent = useAnimatedGestureHandler<PanGestureHandlerGestureEvent, {panOffset: vec.Vector<number>; pan: vec.Vector<number>}>({
|
|
onInit: (_, ctx) => {
|
|
ctx.panOffset = vec.create(0, 0);
|
|
},
|
|
|
|
shouldHandleEvent: () => {
|
|
return (
|
|
scale.value > 1 &&
|
|
(typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) &&
|
|
interactionsEnabled.value
|
|
);
|
|
},
|
|
|
|
beforeEach: (evt, ctx) => {
|
|
ctx.pan = vec.create(evt.translationX, evt.translationY);
|
|
const velocity = vec.create(evt.velocityX, evt.velocityY);
|
|
|
|
vec.set(panVelocity, velocity);
|
|
},
|
|
|
|
onStart: (_, ctx) => {
|
|
cancelAnimation(offset.x);
|
|
cancelAnimation(offset.y);
|
|
ctx.panOffset = vec.create(0, 0);
|
|
onInteraction('pan');
|
|
},
|
|
|
|
onActive: (evt, ctx) => {
|
|
panState.value = evt.state;
|
|
|
|
if (scale.value > 1) {
|
|
if (evt.numberOfPointers > 1) {
|
|
// store pan offset during the pan with two fingers (during the pinch)
|
|
vec.set(ctx.panOffset, ctx.pan);
|
|
} else {
|
|
// subtract the offset and assign fixed pan
|
|
const nextTranslate = vec.add(ctx.pan, vec.invert(ctx.panOffset));
|
|
translation.x.value = nextTranslate.x;
|
|
|
|
if (canPanVertically.value) {
|
|
translation.y.value = nextTranslate.y;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
onEnd: (evt, ctx) => {
|
|
panState.value = evt.state;
|
|
|
|
vec.set(ctx.panOffset, 0);
|
|
vec.set(offset, vec.add(offset, translation));
|
|
vec.set(translation, 0);
|
|
|
|
maybeRunOnEnd();
|
|
|
|
vec.set(panVelocity, 0);
|
|
},
|
|
});
|
|
|
|
useAnimatedReaction(
|
|
() => {
|
|
if (typeof isActive === 'undefined') {
|
|
return true;
|
|
}
|
|
|
|
return isActive.value;
|
|
},
|
|
(currentActive) => {
|
|
if (!currentActive) {
|
|
resetSharedState();
|
|
}
|
|
},
|
|
);
|
|
|
|
const onScaleEvent = useAnimatedGestureHandler<
|
|
PinchGestureHandlerGestureEvent,
|
|
{
|
|
origin: vec.Vector<number>;
|
|
adjustFocal: vec.Vector<number>;
|
|
gestureScale: number;
|
|
nextScale: number;
|
|
}
|
|
>({
|
|
onInit: (_, ctx) => {
|
|
ctx.origin = vec.create(0, 0);
|
|
ctx.gestureScale = 1;
|
|
ctx.adjustFocal = vec.create(0, 0);
|
|
},
|
|
|
|
shouldHandleEvent: (evt) => {
|
|
return (
|
|
evt.numberOfPointers === 2 &&
|
|
(typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) &&
|
|
interactionsEnabled.value
|
|
);
|
|
},
|
|
|
|
beforeEach: (evt, ctx) => {
|
|
// calculate the overall scale value
|
|
// also limits this.event.scale
|
|
ctx.nextScale = clamp(evt.scale * scaleOffset.value, MIN_SCALE, MAX_SCALE + OVER_SCALE);
|
|
|
|
if (ctx.nextScale > MIN_SCALE && ctx.nextScale < MAX_SCALE + OVER_SCALE) {
|
|
ctx.gestureScale = evt.scale;
|
|
}
|
|
|
|
// this is just to be able to use with vectors
|
|
const focal = vec.create(evt.focalX, evt.focalY);
|
|
const CENTER = vec.divide(canvas, 2);
|
|
|
|
// since it works even when you release one finger
|
|
if (evt.numberOfPointers === 2) {
|
|
// focal with translate offset
|
|
// it alow us to scale into different point even then we pan the image
|
|
ctx.adjustFocal = vec.sub(focal, vec.add(CENTER, offset));
|
|
} else if (evt.state === State.ACTIVE && evt.numberOfPointers !== 2) {
|
|
disablePinch();
|
|
}
|
|
},
|
|
|
|
afterEach: (evt, ctx) => {
|
|
if (evt.state === State.END || evt.state === State.CANCELLED) {
|
|
return;
|
|
}
|
|
|
|
scale.value = ctx.nextScale;
|
|
},
|
|
|
|
onStart: (_, ctx) => {
|
|
onInteraction('scale');
|
|
cancelAnimation(offset.x);
|
|
cancelAnimation(offset.y);
|
|
vec.set(ctx.origin, ctx.adjustFocal);
|
|
},
|
|
|
|
onActive: (evt, ctx) => {
|
|
pinchState.value = evt.state;
|
|
|
|
const pinch = vec.sub(ctx.adjustFocal, ctx.origin);
|
|
|
|
const nextTranslation = vec.add(pinch, ctx.origin, vec.multiply(-1, ctx.gestureScale, ctx.origin));
|
|
|
|
vec.set(scaleTranslation, nextTranslation);
|
|
},
|
|
|
|
onFinish: (evt, ctx) => {
|
|
// reset gestureScale value
|
|
ctx.gestureScale = 1;
|
|
pinchState.value = evt.state;
|
|
|
|
// store scale value
|
|
scaleOffset.value = scale.value;
|
|
|
|
vec.set(offset, vec.add(offset, scaleTranslation));
|
|
vec.set(scaleTranslation, 0);
|
|
|
|
if (scaleOffset.value < 1) {
|
|
// make sure we don't add stuff below the 1
|
|
scaleOffset.value = 1;
|
|
|
|
// this runs the timing animation
|
|
scale.value = withTiming(1, timingConfig);
|
|
} else if (scaleOffset.value > MAX_SCALE) {
|
|
scaleOffset.value = MAX_SCALE;
|
|
scale.value = withTiming(MAX_SCALE, timingConfig);
|
|
}
|
|
|
|
maybeRunOnEnd();
|
|
},
|
|
});
|
|
|
|
const onTapEvent = useAnimatedGestureHandler({
|
|
shouldHandleEvent: (evt) => {
|
|
return (
|
|
evt.numberOfPointers === 1 &&
|
|
(typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) &&
|
|
interactionsEnabled.value && scale.value === 1
|
|
);
|
|
},
|
|
|
|
onStart: () => {
|
|
cancelAnimation(offset.x);
|
|
cancelAnimation(offset.y);
|
|
},
|
|
|
|
onActive: () => {
|
|
onTap(scale.value > 1);
|
|
},
|
|
|
|
onEnd: () => {
|
|
maybeRunOnEnd();
|
|
},
|
|
});
|
|
|
|
function handleScaleTo(x: number, y: number) {
|
|
'worklet';
|
|
|
|
scale.value = withTiming(DOUBLE_TAP_SCALE, timingConfig);
|
|
scaleOffset.value = DOUBLE_TAP_SCALE;
|
|
|
|
const targetImageSize = vec.multiply(image, DOUBLE_TAP_SCALE);
|
|
|
|
const CENTER = vec.divide(canvas, 2);
|
|
const imageCenter = vec.divide(image, 2);
|
|
|
|
const focal = vec.create(x, y);
|
|
|
|
const origin = vec.multiply(
|
|
-1,
|
|
vec.sub(vec.divide(targetImageSize, 2), CENTER),
|
|
);
|
|
|
|
const koef = vec.sub(
|
|
vec.multiply(vec.divide(1, imageCenter), focal),
|
|
1,
|
|
);
|
|
|
|
const target = vec.multiply(origin, koef);
|
|
|
|
if (targetImageSize.y < canvas.y) {
|
|
target.y = 0;
|
|
}
|
|
|
|
offset.x.value = withTiming(target.x, timingConfig);
|
|
offset.y.value = withTiming(target.y, timingConfig);
|
|
}
|
|
|
|
const onDoubleTapEvent = useAnimatedGestureHandler<TapGestureHandlerGestureEvent, {}>({
|
|
shouldHandleEvent: (evt) => {
|
|
return (
|
|
evt.numberOfPointers === 1 &&
|
|
(typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) &&
|
|
interactionsEnabled.value
|
|
);
|
|
},
|
|
|
|
onActive: ({x, y}) => {
|
|
onDoubleTap(scale.value > 1);
|
|
|
|
if (scale.value > 1) {
|
|
resetSharedState(true);
|
|
} else {
|
|
handleScaleTo(x, y);
|
|
}
|
|
},
|
|
});
|
|
|
|
const animatedStyles = useAnimatedStyle(() => {
|
|
const noOffset = offset.x.value === 0 && offset.y.value === 0;
|
|
const noTranslation = translation.x.value === 0 && translation.y.value === 0;
|
|
const noScaleTranslation = scaleTranslation.x.value === 0 && scaleTranslation.y.value === 0;
|
|
const isInactive = scale.value === 1 && noOffset && noTranslation && noScaleTranslation;
|
|
|
|
onStateChange(isInactive);
|
|
|
|
return {
|
|
transform: [
|
|
{
|
|
translateX:
|
|
scaleTranslation.x.value +
|
|
translation.x.value +
|
|
offset.x.value,
|
|
},
|
|
{
|
|
translateY:
|
|
scaleTranslation.y.value +
|
|
translation.y.value +
|
|
offset.y.value,
|
|
},
|
|
{scale: scale.value},
|
|
],
|
|
};
|
|
}, []);
|
|
|
|
let element;
|
|
if (isSvg) {
|
|
element = (
|
|
<SvgUri
|
|
uri={imageSource.uri!}
|
|
style={styles.svg}
|
|
width={Math.min(targetDimensions.width, targetDimensions.height)}
|
|
height={Math.min(targetDimensions.width, targetDimensions.height)}
|
|
onLayout={onLoadImageSuccess}
|
|
/>
|
|
);
|
|
} else {
|
|
element = (
|
|
<FastImage
|
|
onLoad={onLoadImageSuccess}
|
|
source={imageSource}
|
|
style={{width: targetWidth, height: targetHeight}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Animated.View style={[styles.container]}>
|
|
<PinchGestureHandler
|
|
enabled={enabled && pinchEnabled}
|
|
ref={pinchRef}
|
|
onGestureEvent={onScaleEvent}
|
|
simultaneousHandlers={[panRef, tapRef, ...outerGestureHandlerRefs]}
|
|
>
|
|
<Animated.View style={[StyleSheet.absoluteFill]}>
|
|
<PanGestureHandler
|
|
enabled={enabled}
|
|
ref={panRef}
|
|
minDist={4}
|
|
avgTouches={true}
|
|
simultaneousHandlers={[pinchRef, tapRef, ...outerGestureHandlerRefs]}
|
|
onGestureEvent={onPanEvent}
|
|
>
|
|
<Animated.View style={[StyleSheet.absoluteFill]}>
|
|
<TapGestureHandler
|
|
enabled={enabled}
|
|
ref={tapRef}
|
|
numberOfTaps={1}
|
|
maxDeltaX={8}
|
|
maxDeltaY={8}
|
|
simultaneousHandlers={[pinchRef, panRef, ...outerGestureHandlerRefs]}
|
|
waitFor={doubleTapRef}
|
|
onGestureEvent={onTapEvent}
|
|
>
|
|
<Animated.View style={styles.wrapper}>
|
|
<TapGestureHandler
|
|
enabled={enabled}
|
|
ref={doubleTapRef}
|
|
numberOfTaps={2}
|
|
maxDelayMs={140}
|
|
maxDeltaX={16}
|
|
maxDeltaY={16}
|
|
simultaneousHandlers={[pinchRef, panRef, ...outerGestureHandlerRefs]}
|
|
onGestureEvent={onDoubleTapEvent}
|
|
>
|
|
<Animated.View style={animatedStyles}>
|
|
{element}
|
|
</Animated.View>
|
|
</TapGestureHandler>
|
|
</Animated.View>
|
|
</TapGestureHandler>
|
|
</Animated.View>
|
|
</PanGestureHandler>
|
|
</Animated.View>
|
|
</PinchGestureHandler>
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
export default React.memo(ImageTransformer);
|
|
|