Files
outline/shared/editor/components/ImageZoom/Controlled.tsx
Tom Moor 717c9b5d64 tsc
2024-01-30 19:48:18 -05:00

585 lines
15 KiB
TypeScript

import React, {
CSSProperties,
Component,
ImgHTMLAttributes,
KeyboardEvent,
MouseEvent,
ReactElement,
ReactNode,
SyntheticEvent,
createRef,
} from "react";
import { createPortal } from "react-dom";
import { VisuallyHidden } from "reakit";
import type { SupportedImage } from "./types";
import {
getImgAlt,
getImgSrc,
getStyleModalImg,
testDiv,
testImg,
testSvg,
} from "./utils";
let elDialogContainer: HTMLDivElement;
if (typeof document !== "undefined") {
elDialogContainer = document.createElement("div");
elDialogContainer.setAttribute("data-rmiz-portal", "");
document.body.appendChild(elDialogContainer);
}
const enum ModalState {
LOADED = "LOADED",
LOADING = "LOADING",
UNLOADED = "UNLOADED",
UNLOADING = "UNLOADING",
}
interface BodyAttrs {
overflow: string;
width: string;
}
const defaultBodyAttrs: BodyAttrs = {
overflow: "",
width: "",
};
export interface ControlledProps {
children: ReactNode;
classDialog?: string;
isZoomed: boolean;
onZoomChange?: (value: boolean) => void;
wrapElement?: "div" | "span";
ZoomContent?: (data: {
img: ReactElement | null;
modalState: ModalState;
onUnzoom: () => void;
}) => ReactElement;
zoomImg?: ImgHTMLAttributes<HTMLImageElement>;
zoomMargin?: number;
}
export function Controlled(props: ControlledProps) {
return <ControlledBase {...props} />;
}
interface ControlledDefaultProps {
wrapElement: "div" | "span";
zoomMargin: number;
}
type ControlledPropsWithDefaults = ControlledDefaultProps & ControlledProps;
interface ControlledState {
id: string;
isZoomImgLoaded: boolean;
loadedImgEl: HTMLImageElement | undefined;
modalState: ModalState;
shouldRefresh: boolean;
}
class ControlledBase extends Component<
ControlledPropsWithDefaults,
ControlledState
> {
static defaultProps: ControlledDefaultProps = {
wrapElement: "div",
zoomMargin: 0,
};
state: ControlledState = {
id: "",
isZoomImgLoaded: false,
loadedImgEl: undefined,
modalState: ModalState.UNLOADED,
shouldRefresh: false,
};
private refContent = createRef<HTMLDivElement>();
private refDialog = createRef<HTMLDialogElement>();
private refModalContent = createRef<HTMLDivElement>();
private refModalImg = createRef<HTMLImageElement>();
private refWrap = createRef<HTMLDivElement>();
private changeObserver: MutationObserver | undefined;
private imgEl: SupportedImage | null = null;
private imgElObserver: ResizeObserver | undefined;
private prevBodyAttrs: BodyAttrs = defaultBodyAttrs;
private styleModalImg: CSSProperties = {};
private touchYStart?: number;
private touchYEnd?: number;
render() {
const {
handleDialogCancel,
handleDialogClick,
handleDialogKeyDown,
handleUnzoom,
imgEl,
props: {
children,
classDialog,
isZoomed,
wrapElement: WrapElement,
ZoomContent,
zoomImg,
zoomMargin,
},
refContent,
refDialog,
refModalContent,
refModalImg,
refWrap,
state: { id, isZoomImgLoaded, loadedImgEl, modalState, shouldRefresh },
} = this;
const idModal = `rmiz-modal-${id}`;
const idModalImg = `rmiz-modal-img-${id}`;
// =========================================================================
const isDiv = testDiv(imgEl);
const isImg = testImg(imgEl);
const isSvg = testSvg(imgEl);
const imgAlt = getImgAlt(imgEl);
const imgSrc = getImgSrc(imgEl);
const imgSizes = isImg ? imgEl.sizes : undefined;
const imgSrcSet = isImg ? imgEl.srcset : undefined;
const hasZoomImg = !!zoomImg?.src;
const hasImage =
imgEl &&
(loadedImgEl || isSvg) &&
window.getComputedStyle(imgEl).display !== "none";
const isModalActive =
modalState === ModalState.LOADING || modalState === ModalState.LOADED;
const dataContentState = hasImage ? "found" : "not-found";
const dataOverlayState =
modalState === ModalState.UNLOADED || modalState === ModalState.UNLOADING
? "hidden"
: "visible";
// =========================================================================
const styleContent: CSSProperties = {
visibility: modalState === ModalState.UNLOADED ? "visible" : "hidden",
};
// Share this with UNSAFE_handleSvg
this.styleModalImg = hasImage
? getStyleModalImg({
hasZoomImg,
imgSrc,
isSvg,
isZoomed: isZoomed && isModalActive,
loadedImgEl,
offset: zoomMargin,
shouldRefresh,
targetEl: imgEl,
})
: {};
// =========================================================================
let modalContent = null;
if (hasImage) {
const modalImg =
isImg || isDiv ? (
<img
alt={imgAlt}
sizes={imgSizes}
src={imgSrc}
srcSet={imgSrcSet}
{...(isZoomImgLoaded && modalState === ModalState.LOADED
? zoomImg
: {})}
data-rmiz-modal-img=""
height={this.styleModalImg.height || undefined}
id={idModalImg}
ref={refModalImg}
style={this.styleModalImg}
width={this.styleModalImg.width || undefined}
/>
) : isSvg ? (
<div
data-rmiz-modal-img
ref={refModalImg}
style={this.styleModalImg}
/>
) : null;
modalContent = ZoomContent ? (
<ZoomContent
modalState={modalState}
img={modalImg}
onUnzoom={handleUnzoom}
/>
) : (
modalImg
);
}
// =========================================================================
return (
<WrapElement aria-owns={idModal} data-rmiz="" ref={refWrap}>
<WrapElement
data-rmiz-content={dataContentState}
ref={refContent}
style={styleContent}
>
{children}
</WrapElement>
{hasImage &&
elDialogContainer !== null &&
createPortal(
<dialog
aria-labelledby={idModalImg}
aria-modal="true"
className={classDialog}
data-rmiz-modal=""
id={idModal}
onClick={handleDialogClick}
onClose={handleUnzoom}
onCancel={handleDialogCancel}
onKeyDown={handleDialogKeyDown}
ref={refDialog}
role="dialog"
>
<div data-rmiz-modal-overlay={dataOverlayState} />
<div data-rmiz-modal-content="" ref={refModalContent}>
{modalContent}
<VisuallyHidden>
<button onClick={handleUnzoom}>Close</button>
</VisuallyHidden>
</div>
</dialog>,
elDialogContainer
)}
</WrapElement>
);
}
componentDidMount() {
this.setId();
this.setAndTrackImg();
this.handleImgLoad();
this.UNSAFE_handleSvg();
}
componentWillUnmount() {
this.changeObserver?.disconnect?.();
this.imgElObserver?.disconnect?.();
this.imgEl?.removeEventListener?.("load", this.handleImgLoad);
this.imgEl?.removeEventListener?.("click", this.handleZoom);
this.refModalImg.current?.removeEventListener?.(
"transitionend",
this.handleZoomEnd
);
this.refModalImg.current?.removeEventListener?.(
"transitionend",
this.handleUnzoomEnd
);
window.removeEventListener("wheel", this.handleWheel);
window.removeEventListener("touchstart", this.handleTouchStart);
window.removeEventListener("touchend", this.handleTouchMove);
window.removeEventListener("touchcancel", this.handleTouchCancel);
window.removeEventListener("resize", this.handleResize);
}
componentDidUpdate(prevProps: ControlledPropsWithDefaults) {
this.UNSAFE_handleSvg();
this.handleIfZoomChanged(prevProps.isZoomed);
}
// Because of SSR, set a unique ID after render
setId = () => {
const gen4 = () => Math.random().toString(16).slice(-4);
this.setState({ id: gen4() + gen4() + gen4() });
};
// Find and set the image we're working with
setAndTrackImg = () => {
const contentEl = this.refContent.current;
if (!contentEl) {
return;
}
this.imgEl = contentEl.querySelector(
'img:not([aria-hidden="true"])'
) as SupportedImage | null;
if (this.imgEl) {
this.changeObserver?.disconnect?.();
this.imgEl?.addEventListener?.("load", this.handleImgLoad);
this.imgEl?.addEventListener?.("click", this.handleZoom);
if (!this.state.loadedImgEl) {
this.handleImgLoad();
}
this.imgElObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry?.target) {
this.imgEl = entry.target as SupportedImage;
this.setState({}); // Force a re-render
}
});
this.imgElObserver.observe(this.imgEl);
} else if (!this.changeObserver) {
this.changeObserver = new MutationObserver(this.setAndTrackImg);
this.changeObserver.observe(contentEl, {
childList: true,
subtree: true,
});
}
};
// Show modal when zoomed; hide modal when unzoomed
handleIfZoomChanged = (prevIsZoomed: boolean) => {
const { isZoomed } = this.props;
if (!prevIsZoomed && isZoomed) {
this.zoom();
} else if (prevIsZoomed && !isZoomed) {
this.unzoom();
}
};
// Ensure we always have the latest img src value loaded
handleImgLoad = () => {
const { imgEl } = this;
const imgSrc = getImgSrc(imgEl);
if (!imgSrc) {
return;
}
const img = new Image();
if (testImg(imgEl)) {
img.sizes = imgEl.sizes;
img.srcset = imgEl.srcset;
}
// img.src must be set after sizes and srcset
// because of Firefox flickering on zoom
img.src = imgSrc;
const setLoaded = () => {
this.setState({ loadedImgEl: img });
};
img
.decode()
.then(setLoaded)
.catch(() => {
img.onload = setLoaded;
});
};
// Report zoom state changes
handleZoom = () => {
this.props.onZoomChange?.(true);
};
handleUnzoom = () => {
this.props.onZoomChange?.(false);
};
// Prevent the browser from removing the dialog on Escape
handleDialogCancel = (e: SyntheticEvent) => {
e.preventDefault();
};
// Have dialog.click() only close in certain situations
handleDialogClick = (e: MouseEvent<HTMLDialogElement>) => {
if (
e.target === this.refModalContent.current ||
e.target === this.refModalImg.current
) {
this.handleUnzoom();
}
};
// Intercept default dialog.close() and use ours so we can animate
handleDialogKeyDown = (e: KeyboardEvent<HTMLDialogElement>) => {
if (e.key === "Escape" || e.keyCode === 27) {
e.preventDefault();
e.stopPropagation();
this.handleUnzoom();
}
};
// Handle wheel and swipe events
handleWheel = (e: WheelEvent) => {
e.stopPropagation();
queueMicrotask(() => {
this.handleUnzoom();
});
};
handleTouchStart = (e: TouchEvent) => {
if (e.changedTouches.length === 1 && e.changedTouches[0]) {
this.touchYStart = e.changedTouches[0].screenY;
}
};
handleTouchMove = (e: TouchEvent) => {
if (this.touchYStart !== null && e.changedTouches[0]) {
this.touchYEnd = e.changedTouches[0].screenY;
const max = Math.max(this.touchYStart || 0, this.touchYEnd);
const min = Math.min(this.touchYStart || 0, this.touchYEnd);
const delta = Math.abs(max - min);
const threshold = 10;
if (delta > threshold) {
this.touchYStart = undefined;
this.touchYEnd = undefined;
this.handleUnzoom();
}
}
};
handleTouchCancel = () => {
this.touchYStart = undefined;
this.touchYEnd = undefined;
};
// Force re-render on resize
handleResize = () => {
this.setState({ shouldRefresh: true });
};
// Perform zoom actions
zoom = () => {
this.refDialog.current?.showModal?.();
this.setState({ modalState: ModalState.LOADING });
this.loadZoomImg();
window.addEventListener("wheel", this.handleWheel, { passive: true });
window.addEventListener("touchstart", this.handleTouchStart, {
passive: true,
});
window.addEventListener("touchend", this.handleTouchMove, {
passive: true,
});
window.addEventListener("touchcancel", this.handleTouchCancel, {
passive: true,
});
this.refModalImg.current?.addEventListener?.(
"transitionend",
this.handleZoomEnd,
{ once: true }
);
};
handleZoomEnd = () => {
setTimeout(() => {
this.setState({ modalState: ModalState.LOADED });
window.addEventListener("resize", this.handleResize, { passive: true });
}, 0);
};
// Perform unzoom actions
unzoom = () => {
this.setState({ modalState: ModalState.UNLOADING });
window.removeEventListener("wheel", this.handleWheel);
window.removeEventListener("touchstart", this.handleTouchStart);
window.removeEventListener("touchend", this.handleTouchMove);
window.removeEventListener("touchcancel", this.handleTouchCancel);
this.refModalImg.current?.addEventListener?.(
"transitionend",
this.handleUnzoomEnd,
{ once: true }
);
};
handleUnzoomEnd = () => {
setTimeout(() => {
window.removeEventListener("resize", this.handleResize);
this.setState({
shouldRefresh: false,
modalState: ModalState.UNLOADED,
});
this.refDialog.current?.close?.();
}, 0);
};
// Load the zoomImg manually
loadZoomImg = () => {
const {
props: { zoomImg },
} = this;
const zoomImgSrc = zoomImg?.src;
if (zoomImgSrc) {
const img = new Image();
img.sizes = zoomImg?.sizes ?? "";
img.srcset = zoomImg?.srcSet ?? "";
img.src = zoomImgSrc;
const setLoaded = () => {
this.setState({ isZoomImgLoaded: true });
};
img
.decode()
.then(setLoaded)
.catch(() => {
img.onload = setLoaded;
});
}
};
// Hackily deal with SVGs because of all of their unknowns.
UNSAFE_handleSvg = () => {
const { imgEl, refModalImg, styleModalImg } = this;
if (testSvg(imgEl)) {
const tmp = document.createElement("div");
tmp.innerHTML = imgEl.outerHTML;
const svg = tmp.firstChild as SVGSVGElement;
svg.style.width = `${styleModalImg.width || 0}px`;
svg.style.height = `${styleModalImg.height || 0}px`;
svg.addEventListener("click", this.handleUnzoom);
refModalImg.current?.firstChild?.remove?.();
refModalImg.current?.appendChild?.(svg);
}
};
}