433 lines
12 KiB
TypeScript
Executable File
433 lines
12 KiB
TypeScript
Executable File
/* eslint-disable jsx-a11y/alt-text */
|
|
import React, {
|
|
useRef,
|
|
useState,
|
|
useLayoutEffect,
|
|
useEffect,
|
|
useMemo,
|
|
useContext,
|
|
} from "react";
|
|
import { ImageViewerProps, ImageViewerClassNameContract } from "./ImageViewer.props";
|
|
import { DesignSystem } from "@microsoft/fast-components-styles-msft";
|
|
import manageJss, { ComponentStyles, CSSRules } from "@microsoft/fast-jss-manager-react";
|
|
import {
|
|
motion,
|
|
useMotionValue,
|
|
TapHandlers,
|
|
MotionValue,
|
|
useDomEvent,
|
|
} from "framer-motion";
|
|
import { headerHeight } from "../../../_DesignSystem";
|
|
import { useViewportDimensions } from "./useViewportDimensions";
|
|
import { classNames } from "@microsoft/fast-web-utilities";
|
|
import ImageViewerSlider from "./ImageViewerSlider";
|
|
import { TweenProps, spring } from "popmotion";
|
|
import { SidebarData } from "../FVSidebar/FVSidebarContext";
|
|
import { debounce } from "lodash-es";
|
|
|
|
const applyCenteredAbsolute: CSSRules<DesignSystem> = {
|
|
position: "absolute",
|
|
userSelect: "none",
|
|
top: headerHeight + "px",
|
|
left: "0",
|
|
right: "0",
|
|
bottom: "0",
|
|
margin: "auto",
|
|
"& img": {
|
|
width: "100%",
|
|
height: "100%",
|
|
},
|
|
};
|
|
|
|
const styles: ComponentStyles<ImageViewerClassNameContract, DesignSystem> = {
|
|
imageViewer: {
|
|
flexGrow: "1",
|
|
position: "relative",
|
|
"& > iframe": {
|
|
opacity: 0,
|
|
pointerEvents: "none",
|
|
},
|
|
},
|
|
imageViewer_imageContainer: {
|
|
...applyCenteredAbsolute,
|
|
cursor: "zoom-in",
|
|
},
|
|
imageViewer__zoomedin: {
|
|
cursor: "grab",
|
|
},
|
|
imageViewer__dragging: {
|
|
cursor: "grabbing",
|
|
},
|
|
};
|
|
|
|
// Used to determine whether user clicked or panned
|
|
let lastDragPoint = { x: 0, y: 0 };
|
|
|
|
/**
|
|
* Animation function to be used with `MotionValue.start()`.
|
|
* It uses popmotion's inbuilt spring function.
|
|
*
|
|
* @param motionValue The `MotionValue` you attach this function to.
|
|
* @param config A custom config for popmotion's tween function.
|
|
*/
|
|
const animateWithSpring = (motionValue: MotionValue, config: TweenProps) => (
|
|
complete: () => void
|
|
) => {
|
|
const animation = spring({
|
|
to: 0,
|
|
...config,
|
|
velocity: motionValue.getVelocity(),
|
|
from: motionValue.get(),
|
|
stiffness: 400,
|
|
damping: 60,
|
|
}).start({
|
|
complete,
|
|
update: (val: number) => motionValue.set(val),
|
|
});
|
|
|
|
return animation.stop;
|
|
};
|
|
|
|
/**
|
|
* The main ImageViewer Component
|
|
*/
|
|
const ImageViewer: React.ComponentType<ImageViewerProps> = ({
|
|
imageURL,
|
|
managedClasses,
|
|
fileData,
|
|
zoomRef,
|
|
}) => {
|
|
fileData = fileData || window.fileData;
|
|
const { id, title, width, height } = fileData;
|
|
|
|
/**
|
|
* We pre-render the image server-side,
|
|
* so that users who are unable to enjoy the full experience
|
|
* can also at least see the actual image.
|
|
*
|
|
* This will remove that pre-rendered image, since the web-app
|
|
* has already rendered it on its own.
|
|
*/
|
|
const onImageLoaded = () => {
|
|
const container = document.getElementById("preContainer");
|
|
if (container) container.remove();
|
|
};
|
|
|
|
/**
|
|
* React.Ref from the draggable component
|
|
*/
|
|
const draggableRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Viewport dimensions
|
|
const [resizeListener, { viewportWidth, viewportHeight }] = useViewportDimensions();
|
|
|
|
/**
|
|
* Used for detecting the current state of the sidebar
|
|
*/
|
|
const { sidebarPos, isSidebarFloating } = useContext(SidebarData);
|
|
|
|
/**
|
|
* Debounce sidebar position changes, so that we can avoid
|
|
* expensive calculations and renderings
|
|
*/
|
|
const [debouncedSidebarPos, setSidebarPos] = useState(sidebarPos.get());
|
|
useEffect(() => {
|
|
const listener = () => setSidebarPos(isSidebarFloating ? 0 : sidebarPos.get());
|
|
listener();
|
|
return sidebarPos.onChange(debounce(listener, 20));
|
|
}, [isSidebarFloating, sidebarPos]);
|
|
|
|
/**
|
|
* Minimum scale factor.
|
|
*
|
|
* It can be used to determine the default size
|
|
* of the image, hence the name.
|
|
*/
|
|
const defaultScale = useMemo(() => {
|
|
let newScaleFactor = 1;
|
|
const aspectRatio = width / height;
|
|
const adjustedViewportWidth = viewportWidth - debouncedSidebarPos;
|
|
|
|
if (width - adjustedViewportWidth > 0 || height - viewportHeight > 0) {
|
|
if (viewportHeight * aspectRatio <= adjustedViewportWidth) {
|
|
const adaptedWidth = width * (viewportHeight / height);
|
|
newScaleFactor = adaptedWidth / width;
|
|
} else {
|
|
const adaptedHeight = height * (adjustedViewportWidth / width);
|
|
newScaleFactor = adaptedHeight / height;
|
|
}
|
|
}
|
|
|
|
return newScaleFactor;
|
|
}, [debouncedSidebarPos, height, viewportHeight, viewportWidth, width]);
|
|
|
|
/**
|
|
* Current scale factor
|
|
*
|
|
* This number can be manipulated by the user.
|
|
*/
|
|
|
|
const [scale, setScaleState] = useState(defaultScale);
|
|
|
|
/**
|
|
* If activated, <ImageViewerSlider/> appears and the image
|
|
* is draggable
|
|
*/
|
|
const [inTransformMode, setTransformState] = useState(false);
|
|
|
|
/**
|
|
* Resizes the image based on the wheel direction and
|
|
* toggles transform mode if needed.
|
|
*/
|
|
const onDraggableWheel = (e: WheelEvent) => {
|
|
const newScaleFactor = Math.min(
|
|
Math.max(scale + (e.deltaY / 150) * -1, defaultScale),
|
|
3
|
|
);
|
|
|
|
setScaleState(newScaleFactor);
|
|
if (
|
|
(newScaleFactor > defaultScale && !inTransformMode) ||
|
|
(newScaleFactor <= defaultScale && inTransformMode)
|
|
) {
|
|
setTransformState(!inTransformMode);
|
|
}
|
|
};
|
|
|
|
// Assign listener to wheel event
|
|
useDomEvent(draggableRef, "wheel", onDraggableWheel as EventListener, {
|
|
passive: false,
|
|
});
|
|
|
|
/**
|
|
* Caches the lastDragPoint for onImageTap
|
|
*/
|
|
const onTapStart: TapHandlers["onTapStart"] = (_e, { point }) => {
|
|
lastDragPoint = point;
|
|
};
|
|
|
|
/**
|
|
* Toggles the transform mode based on whether
|
|
* the user dragged the image.
|
|
*/
|
|
const onTap: TapHandlers["onTap"] = (_e, { point }) => {
|
|
if (point.x === lastDragPoint.x && point.y === lastDragPoint.y)
|
|
setTransformState(!inTransformMode);
|
|
};
|
|
|
|
// Dragging state
|
|
const [isDragging, setDraggingState] = useState(false);
|
|
// Toggle the dragging state
|
|
const onDragStart = () => !isDragging && setDraggingState(true);
|
|
const onDragEnd = () => isDragging && setDraggingState(false);
|
|
|
|
/**
|
|
* Position of the image in the x-axis.
|
|
*
|
|
* Keep in mind that the origin is on the
|
|
* centre of the page.
|
|
*/
|
|
const posX = useMotionValue(0);
|
|
|
|
/**
|
|
* Position of the image in the y-axis.
|
|
*
|
|
* Keep in mind that the origin is on the
|
|
* centre of the page.
|
|
*/
|
|
const posY = useMotionValue(0);
|
|
|
|
/**
|
|
* Calling ref function prop to pass on the handleToggle
|
|
* function as a way to externally toggle the transform mode
|
|
*/
|
|
useLayoutEffect(() => {
|
|
zoomRef(() => setTransformState(!inTransformMode));
|
|
return () => zoomRef(() => {});
|
|
}, [inTransformMode, zoomRef]);
|
|
|
|
/**
|
|
* To keep the image centered if it is bigger than the viewport
|
|
* we adjust the X axis by the delta of the overflowing part
|
|
*/
|
|
const posXAdjusted = useMotionValue<number>(0);
|
|
|
|
/**
|
|
* The amount of overflow of the image in the x axis that
|
|
* needs to be moved back in order to keep the image centered.
|
|
*/
|
|
const deltaX: number = useMemo(() => Math.min(0, (viewportWidth - width) / 2), [
|
|
viewportWidth,
|
|
width,
|
|
]);
|
|
|
|
/**
|
|
* Listen to `deltaX`, `posX` and `sidebarPos` changes and adjust posXAdjusted
|
|
*/
|
|
useLayoutEffect(() => {
|
|
const posXAdjustor = () => {
|
|
posXAdjusted.set(
|
|
posX.get() + deltaX - (isSidebarFloating ? 0 : sidebarPos.get()) / 2
|
|
);
|
|
};
|
|
|
|
posXAdjustor();
|
|
const cleanup1 = posX.onChange(posXAdjustor);
|
|
const cleanup2 = sidebarPos.onChange(posXAdjustor);
|
|
|
|
return () => {
|
|
cleanup1();
|
|
cleanup2();
|
|
};
|
|
}, [deltaX, isSidebarFloating, posX, posXAdjusted, sidebarPos]);
|
|
|
|
/**
|
|
* Calculated constraints that only allow to move the image
|
|
* if it is bigger than the viewport
|
|
*/
|
|
const dragConstraints = useMemo(() => {
|
|
const overflowX = Math.max(width * scale - (viewportWidth - debouncedSidebarPos), 0);
|
|
const overflowY = Math.max(height * scale - viewportHeight, 0);
|
|
return {
|
|
top: -1 * (overflowY / 2),
|
|
bottom: overflowY / 2,
|
|
left: -1 * (overflowX / 2),
|
|
right: overflowX / 2,
|
|
};
|
|
}, [debouncedSidebarPos, height, scale, viewportHeight, viewportWidth, width]);
|
|
|
|
/**
|
|
* Enlargens the image if entering transform mode,
|
|
* resets image size if leaving transform mode.
|
|
*
|
|
* The effect below handles further cleanup tasks.
|
|
*/
|
|
useLayoutEffect(() => {
|
|
if (inTransformMode) setScaleState(defaultScale < 1 ? 1 : 1.2);
|
|
else setScaleState(defaultScale);
|
|
}, [defaultScale, inTransformMode, posX, posY]);
|
|
|
|
/**
|
|
* Move image back to co constraints if it is overflowing.
|
|
* This can happen while scaling down the image.
|
|
*/
|
|
useEffect(() => {
|
|
const { top, bottom, left, right } = dragConstraints;
|
|
const y = posY.get();
|
|
const x = posX.get();
|
|
let newY = y;
|
|
let newX = x;
|
|
|
|
if (y < top) newY = top;
|
|
if (y > bottom) newY = bottom;
|
|
if (x < left) newX = left;
|
|
if (x > right) newX = right;
|
|
|
|
if (newY !== y) {
|
|
posY.stop();
|
|
posY.start(animateWithSpring(posY, { to: newY }));
|
|
}
|
|
|
|
if (newX !== x) {
|
|
posX.stop();
|
|
posX.start(animateWithSpring(posX, { to: newX }));
|
|
}
|
|
}, [dragConstraints, posX, posY]);
|
|
|
|
/**
|
|
* Used for determining whether a magic animation
|
|
* is running.
|
|
*/
|
|
type isMagicAnimRunning = boolean;
|
|
const [isMagicAnimRunning, setMagicAnimState] = useState(false);
|
|
// Toggle `isMagicAnimRunning`
|
|
const onMagicAnimStart = () => !isMagicAnimRunning && setMagicAnimState(true);
|
|
const onMagicAnimEnd = () => isMagicAnimRunning && setMagicAnimState(false);
|
|
|
|
/**
|
|
* Attributes for all img-Tags in this component
|
|
*/
|
|
const sharedImgAttributes = {
|
|
src: imageURL,
|
|
alt: title,
|
|
draggable: false,
|
|
};
|
|
|
|
// Default dimensions used for magic component
|
|
const defaultWidth = useMemo(() => width * defaultScale, [defaultScale, width]);
|
|
const defaultHeight = useMemo(() => height * defaultScale, [defaultScale, height]);
|
|
|
|
return (
|
|
<motion.div className={managedClasses.imageViewer}>
|
|
{resizeListener}
|
|
<motion.div
|
|
/**
|
|
* This component is used for magic animations
|
|
* connecting the cards in the dashboard
|
|
* with this ImageViewer.
|
|
*
|
|
* For the sake of simplicity, we use a seperate
|
|
* component for magic animations and only make it
|
|
* visible if needed.
|
|
*/
|
|
layoutId={`card-image-container-${id}`}
|
|
className={managedClasses.imageViewer_imageContainer}
|
|
onAnimationStart={onMagicAnimStart}
|
|
onAnimationComplete={onMagicAnimEnd}
|
|
tabIndex={-1}
|
|
style={{
|
|
visibility: isMagicAnimRunning ? "visible" : "hidden",
|
|
width: defaultWidth,
|
|
height: defaultHeight,
|
|
}}
|
|
>
|
|
<img {...sharedImgAttributes} tabIndex={-1} />
|
|
</motion.div>
|
|
<motion.div
|
|
className={classNames(
|
|
managedClasses.imageViewer_imageContainer,
|
|
[managedClasses.imageViewer__zoomedin, inTransformMode],
|
|
[managedClasses.imageViewer__dragging, isDragging]
|
|
)}
|
|
ref={draggableRef}
|
|
initial={false}
|
|
animate={{ scale }}
|
|
transition={{ type: "tween", duration: 0.18 }}
|
|
draggable={false}
|
|
drag={inTransformMode}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
dragElastic={0.2}
|
|
dragConstraints={dragConstraints}
|
|
onTapStart={onTapStart}
|
|
onTap={onTap}
|
|
onLoad={onImageLoaded}
|
|
style={{
|
|
display: isMagicAnimRunning ? "none" : "block",
|
|
width: width,
|
|
height: height,
|
|
x: posXAdjusted,
|
|
y: posY,
|
|
}}
|
|
/**
|
|
* Since `posXAdjusted` is set as the value for `x`,
|
|
* we need to tell the component to modify `posX`
|
|
* so that `posXAdjusted` can be calculated
|
|
*/
|
|
_dragValueX={posX}
|
|
>
|
|
<img {...sharedImgAttributes} />
|
|
</motion.div>
|
|
<ImageViewerSlider
|
|
show={inTransformMode}
|
|
value={scale}
|
|
minFactor={defaultScale}
|
|
maxFactor={3}
|
|
onValueChange={setScaleState}
|
|
/>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default manageJss(styles)(ImageViewer);
|