mirror of
https://github.com/outline/outline.git
synced 2025-03-14 10:07:11 +00:00
554 lines
14 KiB
TypeScript
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;
|
|
}
|