2023-04-18 19:49:56 -04:00

554 lines
14 KiB
TypeScript

import { CSSProperties } from "react";
import type { SupportedImage } from "./types";
interface TestElType {
(type: string, el: unknown): boolean;
}
const testElType: TestElType = (type, el) =>
type === (el as Element)?.tagName?.toUpperCase?.();
export const testDiv = (el: unknown): el is HTMLDivElement | HTMLSpanElement =>
testElType("DIV", el) || testElType("SPAN", el);
export const testImg = (el: unknown): el is HTMLImageElement =>
testElType("IMG", el);
export const testSvg = (el: unknown): el is SVGElement => testElType("SVG", el);
export interface GetScaleToWindow {
(data: { width: number; height: number; offset: number }): number;
}
export const getScaleToWindow: GetScaleToWindow = ({ height, offset, width }) =>
Math.min(
(window.innerWidth - offset * 2) / width, // scale X-axis
(window.innerHeight - offset * 2) / height // scale Y-axis
);
export interface GetScaleToWindowMax {
(data: {
containerHeight: number;
containerWidth: number;
offset: number;
targetHeight: number;
targetWidth: number;
}): number;
}
export const getScaleToWindowMax: GetScaleToWindowMax = ({
containerHeight,
containerWidth,
offset,
targetHeight,
targetWidth,
}) => {
const scale = getScaleToWindow({
height: targetHeight,
offset,
width: targetWidth,
});
const ratio =
targetWidth > targetHeight
? targetWidth / containerWidth
: targetHeight / containerHeight;
return scale > 1 ? ratio : scale * ratio;
};
export interface GetScale {
(data: {
containerHeight: number;
containerWidth: number;
hasScalableSrc: boolean;
offset: number;
targetHeight: number;
targetWidth: number;
}): number;
}
export const getScale: GetScale = ({
containerHeight,
containerWidth,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
}) => {
if (!containerHeight || !containerWidth) {
return 1;
}
return !hasScalableSrc && targetHeight && targetWidth
? getScaleToWindowMax({
containerHeight,
containerWidth,
offset,
targetHeight,
targetWidth,
})
: getScaleToWindow({
height: containerHeight,
offset,
width: containerWidth,
});
};
const URL_REGEX = /url(?:\(['"]?)(.*?)(?:['"]?\))/;
export interface GetImgSrc {
(imgEl: SupportedImage | null): string | undefined;
}
export const getImgSrc: GetImgSrc = (imgEl) => {
if (imgEl) {
if (testImg(imgEl)) {
return imgEl.currentSrc;
} else if (testDiv(imgEl)) {
const bgImg = window.getComputedStyle(imgEl).backgroundImage;
if (bgImg) {
return URL_REGEX.exec(bgImg)?.[1];
}
}
}
return;
};
export interface GetImgAlt {
(imgEl: SupportedImage | null): string | undefined;
}
export const getImgAlt: GetImgAlt = (imgEl) => {
if (imgEl) {
if (testImg(imgEl)) {
return imgEl.alt ?? undefined;
} else {
return imgEl.getAttribute("aria-label") ?? undefined;
}
}
return;
};
export interface GetImgRegularStyle {
(data: {
containerHeight: number;
containerLeft: number;
containerTop: number;
containerWidth: number;
hasScalableSrc: boolean;
offset: number;
targetHeight: number;
targetWidth: number;
}): CSSProperties;
}
export const getImgRegularStyle: GetImgRegularStyle = ({
containerHeight,
containerLeft,
containerTop,
containerWidth,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
}) => {
const scale = getScale({
containerHeight,
containerWidth,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
});
return {
top: containerTop,
left: containerLeft,
width: containerWidth * scale,
height: containerHeight * scale,
transform: `translate(0,0) scale(${1 / scale})`,
};
};
export interface ParsePosition {
(data: { position: string; relativeNum: number }): number;
}
export const parsePosition: ParsePosition = ({ position, relativeNum }) => {
const positionNum = parseFloat(position);
return position.endsWith("%")
? (relativeNum * positionNum) / 100
: positionNum;
};
export interface GetImgObjectFitStyle {
(data: {
containerHeight: number;
containerLeft: number;
containerTop: number;
containerWidth: number;
hasScalableSrc: boolean;
objectFit: string;
objectPosition: string;
offset: number;
targetHeight: number;
targetWidth: number;
}): CSSProperties;
}
export const getImgObjectFitStyle: GetImgObjectFitStyle = ({
containerHeight,
containerLeft,
containerTop,
containerWidth,
hasScalableSrc,
objectFit,
objectPosition,
offset,
targetHeight,
targetWidth,
}) => {
if (objectFit === "scale-down") {
if (targetWidth <= containerWidth && targetHeight <= containerHeight) {
objectFit = "none";
} else {
objectFit = "contain";
}
}
if (objectFit === "cover" || objectFit === "contain") {
const widthRatio = containerWidth / targetWidth;
const heightRatio = containerHeight / targetHeight;
const ratio =
objectFit === "cover"
? Math.max(widthRatio, heightRatio)
: Math.min(widthRatio, heightRatio);
const [posLeft = "50%", posTop = "50%"] = objectPosition.split(" ");
const posX = parsePosition({
position: posLeft,
relativeNum: containerWidth - targetWidth * ratio,
});
const posY = parsePosition({
position: posTop,
relativeNum: containerHeight - targetHeight * ratio,
});
const scale = getScale({
containerHeight: targetHeight * ratio,
containerWidth: targetWidth * ratio,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
});
return {
top: containerTop + posY,
left: containerLeft + posX,
width: targetWidth * ratio * scale,
height: targetHeight * ratio * scale,
transform: `translate(0,0) scale(${1 / scale})`,
};
} else if (objectFit === "none") {
const [posLeft = "50%", posTop = "50%"] = objectPosition.split(" ");
const posX = parsePosition({
position: posLeft,
relativeNum: containerWidth - targetWidth,
});
const posY = parsePosition({
position: posTop,
relativeNum: containerHeight - targetHeight,
});
const scale = getScale({
containerHeight: targetHeight,
containerWidth: targetWidth,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
});
return {
top: containerTop + posY,
left: containerLeft + posX,
width: targetWidth * scale,
height: targetHeight * scale,
transform: `translate(0,0) scale(${1 / scale})`,
};
} else if (objectFit === "fill") {
const widthRatio = containerWidth / targetWidth;
const heightRatio = containerHeight / targetHeight;
const ratio = Math.max(widthRatio, heightRatio);
const scale = getScale({
containerHeight: targetHeight * ratio,
containerWidth: targetWidth * ratio,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
});
return {
width: containerWidth * scale,
height: containerHeight * scale,
transform: `translate(0,0) scale(${1 / scale})`,
};
} else {
return {};
}
};
export interface GetDivImgStyle {
(data: {
backgroundPosition: string;
backgroundSize: string;
containerHeight: number;
containerLeft: number;
containerTop: number;
containerWidth: number;
hasScalableSrc: boolean;
offset: number;
targetHeight: number;
targetWidth: number;
}): CSSProperties;
}
export const getDivImgStyle: GetDivImgStyle = ({
backgroundPosition,
backgroundSize,
containerHeight,
containerLeft,
containerTop,
containerWidth,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
}) => {
if (backgroundSize === "cover" || backgroundSize === "contain") {
const widthRatio = containerWidth / targetWidth;
const heightRatio = containerHeight / targetHeight;
const ratio =
backgroundSize === "cover"
? Math.max(widthRatio, heightRatio)
: Math.min(widthRatio, heightRatio);
const [posLeft = "50%", posTop = "50%"] = backgroundPosition.split(" ");
const posX = parsePosition({
position: posLeft,
relativeNum: containerWidth - targetWidth * ratio,
});
const posY = parsePosition({
position: posTop,
relativeNum: containerHeight - targetHeight * ratio,
});
const scale = getScale({
containerHeight: targetHeight * ratio,
containerWidth: targetWidth * ratio,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
});
return {
top: containerTop + posY,
left: containerLeft + posX,
width: targetWidth * ratio * scale,
height: targetHeight * ratio * scale,
transform: `translate(0,0) scale(${1 / scale})`,
};
} else if (backgroundSize === "auto") {
const [posLeft = "50%", posTop = "50%"] = backgroundPosition.split(" ");
const posX = parsePosition({
position: posLeft,
relativeNum: containerWidth - targetWidth,
});
const posY = parsePosition({
position: posTop,
relativeNum: containerHeight - targetHeight,
});
const scale = getScale({
containerHeight: targetHeight,
containerWidth: targetWidth,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
});
return {
top: containerTop + posY,
left: containerLeft + posX,
width: targetWidth * scale,
height: targetHeight * scale,
transform: `translate(0,0) scale(${1 / scale})`,
};
} else {
const [sizeW = "50%", sizeH = "50%"] = backgroundSize.split(" ");
const sizeWidth = parsePosition({
position: sizeW,
relativeNum: containerWidth,
});
const sizeHeight = parsePosition({
position: sizeH,
relativeNum: containerHeight,
});
const widthRatio = sizeWidth / targetWidth;
const heightRatio = sizeHeight / targetHeight;
// @TODO: something funny is happening with this ratio
const ratio = Math.min(widthRatio, heightRatio);
const [posLeft = "50%", posTop = "50%"] = backgroundPosition.split(" ");
const posX = parsePosition({
position: posLeft,
relativeNum: containerWidth - targetWidth * ratio,
});
const posY = parsePosition({
position: posTop,
relativeNum: containerHeight - targetHeight * ratio,
});
const scale = getScale({
containerHeight: targetHeight * ratio,
containerWidth: targetWidth * ratio,
hasScalableSrc,
offset,
targetHeight,
targetWidth,
});
return {
top: containerTop + posY,
left: containerLeft + posX,
width: targetWidth * ratio * scale,
height: targetHeight * ratio * scale,
transform: `translate(0,0) scale(${1 / scale})`,
};
}
};
const SRC_SVG_REGEX = /\.svg$/i;
export interface GetStyleModalImg {
(data: {
hasZoomImg: boolean;
imgSrc: string | undefined;
isSvg: boolean;
isZoomed: boolean;
loadedImgEl: HTMLImageElement | undefined;
offset: number;
shouldRefresh: boolean;
targetEl: SupportedImage;
}): CSSProperties;
}
export const getStyleModalImg: GetStyleModalImg = ({
hasZoomImg,
imgSrc,
isSvg,
isZoomed,
loadedImgEl,
offset,
shouldRefresh,
targetEl,
}) => {
const hasScalableSrc =
isSvg ||
imgSrc?.slice?.(0, 18) === "data:image/svg+xml" ||
hasZoomImg ||
!!(imgSrc && SRC_SVG_REGEX.test(imgSrc));
const imgRect = targetEl.getBoundingClientRect();
const targetElComputedStyle = window.getComputedStyle(targetEl);
const styleImgRegular = getImgRegularStyle({
containerHeight: imgRect.height,
containerLeft: imgRect.left,
containerTop: imgRect.top,
containerWidth: imgRect.width,
hasScalableSrc,
offset,
targetHeight: loadedImgEl?.naturalHeight ?? imgRect.height,
targetWidth: loadedImgEl?.naturalWidth ?? imgRect.width,
});
const styleImgObjectFit =
loadedImgEl && targetElComputedStyle.objectFit
? getImgObjectFitStyle({
containerHeight: imgRect.height,
containerLeft: imgRect.left,
containerTop: imgRect.top,
containerWidth: imgRect.width,
hasScalableSrc,
objectFit: targetElComputedStyle.objectFit,
objectPosition: targetElComputedStyle.objectPosition,
offset,
targetHeight: loadedImgEl.naturalHeight,
targetWidth: loadedImgEl.naturalWidth,
})
: undefined;
const styleDivImg =
loadedImgEl && testDiv(targetEl)
? getDivImgStyle({
backgroundPosition: targetElComputedStyle.backgroundPosition,
backgroundSize: targetElComputedStyle.backgroundSize,
containerHeight: imgRect.height,
containerLeft: imgRect.left,
containerTop: imgRect.top,
containerWidth: imgRect.width,
hasScalableSrc,
offset,
targetHeight: loadedImgEl.naturalHeight,
targetWidth: loadedImgEl.naturalWidth,
})
: undefined;
const style = Object.assign(
{},
styleImgRegular,
styleImgObjectFit,
styleDivImg
);
if (isZoomed) {
const viewportX = window.innerWidth / 2;
const viewportY = window.innerHeight / 2;
const childCenterX =
parseFloat(String(style.left || 0)) +
parseFloat(String(style.width || 0)) / 2;
const childCenterY =
parseFloat(String(style.top || 0)) +
parseFloat(String(style.height || 0)) / 2;
const translateX = viewportX - childCenterX;
const translateY = viewportY - childCenterY;
// For scenarios like resizing the browser window
if (shouldRefresh) {
style.transitionDuration = "0.01ms";
}
style.transform = `translate(${translateX}px,${translateY}px) scale(1)`;
}
return style;
};
export interface GetStyleGhost {
(imgEl: SupportedImage | null): CSSProperties;
}