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:
Tom Moor
2024-06-03 20:26:25 -04:00
committed by GitHub
parent 62ebba1c32
commit 23606dad1d
15 changed files with 207 additions and 1260 deletions

View File

@ -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
View File

@ -0,0 +1 @@
export default null;

View File

@ -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.
*

View File

@ -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",

View File

@ -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,

View 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;
}
}
`;

View File

@ -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);
}
};
}

View File

@ -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.

View File

@ -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;
}
}
`;

View File

@ -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} />
</>
);
}

View File

@ -1,5 +0,0 @@
export type SupportedImage =
| HTMLImageElement
| HTMLDivElement
| HTMLSpanElement
| SVGElement;

View File

@ -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;
}

View File

@ -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"

View File

@ -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.
*

View File

@ -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"