mattermost-mobile/app/screens/gallery/image_renderer/transformer.tsx
Elias Nahum 0c0f92a237
[Gekidou] Markdown SVG & image size support (#6032)
* 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
2022-03-10 09:03:09 -03:00

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);