mirror of
https://github.com/outline/outline.git
synced 2025-03-14 10:07:11 +00:00
Move image zooming back to unvendorized lib (#6980)
* Move image zooming back to unvendorized lib * refactor * perf: Avoid mounting zoom dialog until interacted * Add captions to lightbox * lightbox
This commit is contained in:
@ -7,7 +7,8 @@
|
||||
"roots": ["<rootDir>/server", "<rootDir>/plugins"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
|
||||
@ -22,7 +23,8 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"modulePaths": ["<rootDir>/app"],
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
@ -37,7 +39,8 @@
|
||||
"roots": ["<rootDir>/shared"],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/console.js"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/shared/test/setup.ts"],
|
||||
@ -50,7 +53,8 @@
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js",
|
||||
"react-medium-image-zoom": "<rootDir>/__mocks__/react-medium-image-zoom.js"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/__mocks__/window.js"],
|
||||
"testEnvironment": "jsdom",
|
||||
|
1
__mocks__/react-medium-image-zoom.js
vendored
Normal file
1
__mocks__/react-medium-image-zoom.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
export default null;
|
@ -618,6 +618,13 @@ export class Editor extends React.PureComponent<
|
||||
*/
|
||||
public getHeadings = () => ProsemirrorHelper.getHeadings(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Return the images in the current editor.
|
||||
*
|
||||
* @returns A list of images in the document
|
||||
*/
|
||||
public getImages = () => ProsemirrorHelper.getImages(this.view.state.doc);
|
||||
|
||||
/**
|
||||
* Return the tasks/checkmarks in the current editor.
|
||||
*
|
||||
|
@ -197,6 +197,7 @@
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.41.5",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "^5.2.4",
|
||||
"react-merge-refs": "^2.0.2",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
|
@ -5,7 +5,7 @@ import styled from "styled-components";
|
||||
import { s } from "../../styles";
|
||||
import { sanitizeUrl } from "../../utils/urls";
|
||||
import { ComponentProps } from "../types";
|
||||
import ImageZoom from "./ImageZoom";
|
||||
import { ImageZoom } from "./ImageZoom";
|
||||
import { ResizeLeft, ResizeRight } from "./ResizeHandle";
|
||||
import useDragResize from "./hooks/useDragResize";
|
||||
|
||||
@ -70,7 +70,7 @@ const Image = (props: Props) => {
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
)}
|
||||
<ImageZoom zoomMargin={24}>
|
||||
<ImageZoom caption={props.node.attrs.alt}>
|
||||
<img
|
||||
style={{
|
||||
...widthStyle,
|
||||
|
161
shared/editor/components/ImageZoom.tsx
Normal file
161
shared/editor/components/ImageZoom.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { transparentize } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { createGlobalStyle } from "styled-components";
|
||||
import { s } from "../../styles";
|
||||
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
|
||||
|
||||
type Props = {
|
||||
/** An optional caption to display below the image */
|
||||
caption?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that wraps an image with the ability to zoom in
|
||||
*/
|
||||
export const ImageZoom = ({ caption, children }: Props) => {
|
||||
const Zoom = React.lazy(() => import("react-medium-image-zoom"));
|
||||
const [isActivated, setIsActivated] = React.useState(false);
|
||||
|
||||
const handleActivated = React.useCallback(() => {
|
||||
setIsActivated(true);
|
||||
}, []);
|
||||
|
||||
const fallback = (
|
||||
<span onPointerEnter={handleActivated} onFocus={handleActivated}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!isActivated) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={fallback}>
|
||||
<Styles />
|
||||
<Zoom
|
||||
zoomMargin={EditorStyleHelper.padding}
|
||||
ZoomContent={(props) => <Lightbox caption={caption} {...props} />}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</Zoom>
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const Lightbox = ({
|
||||
caption,
|
||||
modalState,
|
||||
img,
|
||||
}: {
|
||||
caption: string | undefined;
|
||||
modalState: string;
|
||||
img: React.ReactNode;
|
||||
}) => (
|
||||
<figure>
|
||||
{img}
|
||||
<Caption $loaded={modalState === "LOADED"}>{caption}</Caption>
|
||||
</figure>
|
||||
);
|
||||
|
||||
const Caption = styled("figcaption")<{ $loaded: boolean }>`
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: ${EditorStyleHelper.padding}px;
|
||||
font-size: 15px;
|
||||
opacity: ${(props) => (props.$loaded ? 1 : 0)};
|
||||
transition: opacity 250ms;
|
||||
|
||||
font-weight: normal;
|
||||
color: ${s("textSecondary")};
|
||||
`;
|
||||
|
||||
const Styles = createGlobalStyle`
|
||||
[data-rmiz] {
|
||||
position: relative;
|
||||
}
|
||||
[data-rmiz-ghost] {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-rmiz-btn-zoom],
|
||||
[data-rmiz-btn-unzoom] {
|
||||
display: none;
|
||||
}
|
||||
[data-rmiz-btn-zoom]:not(:focus):not(:active) {
|
||||
position: absolute;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
[data-rmiz-btn-zoom] {
|
||||
position: absolute;
|
||||
inset: 10px 10px auto auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
[data-rmiz-btn-unzoom] {
|
||||
position: absolute;
|
||||
inset: 20px 20px auto auto;
|
||||
cursor: zoom-out;
|
||||
z-index: 1;
|
||||
}
|
||||
[data-rmiz-content="found"] img,
|
||||
[data-rmiz-content="found"] svg,
|
||||
[data-rmiz-content="found"] [role="img"],
|
||||
[data-rmiz-content="found"] [data-zoom] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
[data-rmiz-modal]::backdrop {
|
||||
display: none;
|
||||
}
|
||||
[data-rmiz-modal][open] {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-rmiz-modal-overlay] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
[data-rmiz-modal-overlay="hidden"] {
|
||||
background-color: ${(props) => transparentize(1, props.theme.background)};
|
||||
}
|
||||
[data-rmiz-modal-overlay="visible"] {
|
||||
background-color: ${s("background")};
|
||||
}
|
||||
[data-rmiz-modal-content] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
[data-rmiz-modal-img] {
|
||||
position: absolute;
|
||||
cursor: zoom-out;
|
||||
image-rendering: high-quality;
|
||||
transform-origin: top left;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-rmiz-modal-overlay],
|
||||
[data-rmiz-modal-img] {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
`;
|
@ -1,584 +0,0 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
Copyright (c) 2020, Robert Pearce
|
||||
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following
|
||||
disclaimer in the documentation and/or other materials provided
|
||||
with the distribution.
|
||||
|
||||
* Neither the name of Robert Pearce nor the names of other
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
@ -1,61 +0,0 @@
|
||||
import { transparentize } from "polished";
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
import { s } from "../../../styles";
|
||||
|
||||
export default createGlobalStyle`
|
||||
[data-rmiz] {
|
||||
position: relative;
|
||||
}
|
||||
[data-rmiz-content="found"] img,
|
||||
[data-rmiz-content="found"] svg,
|
||||
[data-rmiz-content="found"] [role="img"],
|
||||
[data-rmiz-content="found"] [data-zoom] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
[data-rmiz-modal]::backdrop {
|
||||
display: none;
|
||||
}
|
||||
[data-rmiz-modal][open] {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
width: 100svw;
|
||||
height: 100vh;
|
||||
height: 100svh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-rmiz-modal-overlay] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
[data-rmiz-modal-overlay="hidden"] {
|
||||
background-color: ${(props) => transparentize(1, props.theme.background)};
|
||||
}
|
||||
[data-rmiz-modal-overlay="visible"] {
|
||||
background-color: ${s("background")};
|
||||
}
|
||||
[data-rmiz-modal-content] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
[data-rmiz-modal-img] {
|
||||
position: absolute;
|
||||
cursor: zoom-out;
|
||||
image-rendering: high-quality;
|
||||
transform-origin: top left;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-rmiz-modal-overlay],
|
||||
[data-rmiz-modal-img] {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
`;
|
@ -1,19 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Controlled, ControlledProps } from "./Controlled";
|
||||
import Styles from "./Styles";
|
||||
|
||||
export type UncontrolledProps = Omit<
|
||||
ControlledProps,
|
||||
"isZoomed" | "onZoomChange"
|
||||
>;
|
||||
|
||||
export default function Zoom(props: UncontrolledProps) {
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Styles />
|
||||
<Controlled {...props} isZoomed={isZoomed} onZoomChange={setIsZoomed} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
export type SupportedImage =
|
||||
| HTMLImageElement
|
||||
| HTMLDivElement
|
||||
| HTMLSpanElement
|
||||
| SVGElement;
|
@ -1,553 +0,0 @@
|
||||
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;
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
import * as React from "react";
|
||||
import Frame from "../components/Frame";
|
||||
import ImageZoom from "../components/ImageZoom";
|
||||
import { ImageZoom } from "../components/ImageZoom";
|
||||
import { EmbedProps as Props } from ".";
|
||||
|
||||
function InVision({ matches, ...props }: Props) {
|
||||
if (/opal\.invisionapp\.com/.test(props.attrs.href)) {
|
||||
return (
|
||||
<div className={props.isSelected ? "ProseMirror-selectednode" : ""}>
|
||||
<ImageZoom zoomMargin={24}>
|
||||
<ImageZoom>
|
||||
<img
|
||||
src={props.attrs.href}
|
||||
alt="InVision Embed"
|
||||
|
@ -171,6 +171,26 @@ export class ProsemirrorHelper {
|
||||
return comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the images.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @returns Array<Node> of images
|
||||
*/
|
||||
static getImages(doc: Node): Node[] {
|
||||
const images: Node[] = [];
|
||||
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === "image") {
|
||||
images.push(node);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the tasks and their completion state.
|
||||
*
|
||||
|
@ -13303,6 +13303,11 @@ react-is@^17.0.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||
integrity "sha1-GZQx7qqi4J+GQn77tPFHPttHYJs= sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
|
||||
|
||||
react-medium-image-zoom@^5.2.4:
|
||||
version "5.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-medium-image-zoom/-/react-medium-image-zoom-5.2.4.tgz#a3d4773a40e641484b23ee874b8ad9bc057faed9"
|
||||
integrity sha512-XLu/fLqpbmhiDAGA6yie78tDv4kh8GxvS7kKQArSOvCvm5zvgItoh4h01NAAvnezQ60ovsTeedHiHG3eG9CcGg==
|
||||
|
||||
react-merge-refs@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-2.0.2.tgz#73f576111124897dec4ea56035a97e199e8cb377"
|
||||
|
Reference in New Issue
Block a user