feat: Full width images (#4389)

* feat: Full width images

* lint

* fix: Enable TOC overlaid on full size images

* Vendorize react-medium-image-zoom

* tsc

* fix

* Remove body scroll lock
This commit is contained in:
Tom Moor
2022-12-17 17:17:15 -08:00
committed by GitHub
parent f8ba393f7c
commit acf74b83a8
19 changed files with 1415 additions and 104 deletions

View File

@@ -68,6 +68,7 @@ export default class ComponentView {
const children = this.component({
theme,
node: this.node,
view: this.view,
isSelected: this.isSelected,
isEditable: this.view.editable,
getPos: this.getPos,

View File

@@ -5,6 +5,7 @@ import {
AlignImageLeftIcon,
AlignImageRightIcon,
AlignImageCenterIcon,
AlignFullWidthIcon,
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
@@ -23,6 +24,9 @@ export default function imageMenuItems(
const isRightAligned = isNodeActive(schema.nodes.image, {
layoutClass: "right-50",
});
const isFullWidthAligned = isNodeActive(schema.nodes.image, {
layoutClass: "full-width",
});
return [
{
@@ -40,7 +44,8 @@ export default function imageMenuItems(
active: (state) =>
isNodeActive(schema.nodes.image)(state) &&
!isLeftAligned(state) &&
!isRightAligned(state),
!isRightAligned(state) &&
!isFullWidthAligned(state),
},
{
name: "alignRight",
@@ -49,6 +54,13 @@ export default function imageMenuItems(
visible: true,
active: isRightAligned,
},
{
name: "alignFullWidth",
tooltip: dictionary.alignFullWidth,
icon: <AlignFullWidthIcon />,
visible: true,
active: isFullWidthAligned,
},
{
name: "separator",
visible: true,

View File

@@ -13,6 +13,7 @@ export default function useDictionary() {
alignCenter: t("Align center"),
alignLeft: t("Align left"),
alignRight: t("Align right"),
alignFullWidth: t("Full width"),
bulletList: t("Bulleted list"),
checkboxList: t("Todo list"),
codeBlock: t("Code block"),
@@ -28,9 +29,6 @@ export default function useDictionary() {
deleteImage: t("Delete image"),
downloadImage: t("Download image"),
replaceImage: t("Replace image"),
alignImageLeft: t("Float left"),
alignImageRight: t("Float right"),
alignImageDefault: t("Center large"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
file: t("File attachment"),

View File

@@ -1,3 +1,4 @@
import { transparentize } from "polished";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -100,21 +101,27 @@ const Sticky = styled.div`
top: 80px;
max-height: calc(100vh - 80px);
box-shadow: 1px 0 0 ${(props) => props.theme.divider};
margin-top: 40px;
background: ${(props) => props.theme.background};
margin-top: 72px;
margin-right: 52px;
min-width: 204px;
width: 204px;
width: 228px;
min-height: 40px;
overflow-y: auto;
padding: 4px 16px;
border-radius: 8px;
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
background: ${(props) => transparentize(0.2, props.theme.background)};
}
`;
const Heading = styled.h3`
font-size: 11px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
color: ${(props) => props.theme.sidebarText};
letter-spacing: 0.04em;
color: ${(props) => props.theme.textTertiary};
letter-spacing: 0.03em;
`;
const Empty = styled(Text)`
@@ -128,10 +135,9 @@ const ListItem = styled.li<{ level: number; active?: boolean }>`
margin-bottom: 8px;
padding-right: 2em;
line-height: 1.3;
border-right: 3px solid
${(props) => (props.active ? props.theme.divider : "transparent")};
a {
font-weight: ${(props) => (props.active ? "600" : "inherit")};
color: ${(props) =>
props.active ? props.theme.primary : props.theme.text};
}

View File

@@ -481,7 +481,12 @@ class DocumentScene extends React.Component<Props> {
}
}}
/>
<Background key={revision ? revision.id : document.id} column auto>
<Background
id="full-width-container"
key={revision ? revision.id : document.id}
column
auto
>
<Route
path={`${document.url}/move`}
component={() => (
@@ -562,7 +567,7 @@ class DocumentScene extends React.Component<Props> {
>
<Notices document={document} readOnly={readOnly} />
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly}>
<Flex auto={!readOnly} reverse>
{revision ? (
<RevisionViewer
isDraft={document.isDraft}
@@ -572,12 +577,6 @@ class DocumentScene extends React.Component<Props> {
/>
) : (
<>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
@@ -624,6 +623,13 @@ class DocumentScene extends React.Component<Props> {
</>
)}
</Editor>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
</>
)}
</Flex>

View File

@@ -287,7 +287,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
style={
showCache
? {
display: "none",
opacity: 0,
pointerEvents: "none",
}
: undefined
}

View File

@@ -141,7 +141,7 @@
"node-fetch": "2.6.7",
"node-htmldiff": "^0.9.4",
"nodemailer": "^6.6.1",
"outline-icons": "^1.45.2",
"outline-icons": "^1.46.0",
"oy-vey": "^0.12.0",
"passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0",
@@ -180,7 +180,6 @@
"react-helmet": "^6.1.0",
"react-hook-form": "^7.37.0",
"react-i18next": "^11.16.6",
"react-medium-image-zoom": "^3.1.3",
"react-merge-refs": "^2.0.1",
"react-portal": "^4.2.0",
"react-router-dom": "^5.2.0",
@@ -284,7 +283,6 @@
"@types/react-color": "^3.0.6",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.5",
"@types/react-medium-image-zoom": "^3.0.1",
"@types/react-portal": "^4.0.4",
"@types/react-router-dom": "^5.3.2",
"@types/react-table": "^7.7.9",

View File

@@ -0,0 +1,585 @@
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}
// @ts-expect-error does not exist
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(
':is(img, svg, [role="img"], [data-zoom]):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

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,60 @@
import { transparentize } from "polished";
import { createGlobalStyle } from "styled-components";
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: ${(props) => props.theme.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

@@ -0,0 +1,19 @@
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

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

View File

@@ -0,0 +1,555 @@
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,
}) => {
return 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,
}) => {
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

@@ -247,9 +247,6 @@ li {
pointer-events: ${props.readOnly ? "initial" : "none"};
display: inline-block;
max-width: 100%;
transition-property: width, height;
transition-duration: 150ms;
transition-timing-function: ease-in-out;
}
.ProseMirror-selectednode img {
@@ -289,6 +286,20 @@ li {
clear: initial;
}
.image-full-width {
width: initial;
max-width: 100vw;
clear: both;
position: initial;
img {
max-width: 100vw;
max-height: 50vh;
object-fit: cover;
object-position: center;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import ImageZoom from "react-medium-image-zoom";
import Frame from "../components/Frame";
import ImageZoom from "../components/ImageZoom";
import { EmbedProps as Props } from ".";
const IFRAME_REGEX = /^https:\/\/(invis\.io\/.*)|(projects\.invisionapp\.com\/share\/.*)$/;
@@ -9,19 +9,18 @@ const IMAGE_REGEX = /^https:\/\/(opal\.invisionapp\.com\/static-signed\/live-emb
function InVision(props: Props) {
if (IMAGE_REGEX.test(props.attrs.href)) {
return (
<ImageZoom
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
className={props.isSelected ? "ProseMirror-selectednode" : ""}
image={{
src: props.attrs.href,
alt: "InVision Embed",
style: {
<div className={props.isSelected ? "ProseMirror-selectednode" : ""}>
<ImageZoom zoomMargin={24}>
<img
src={props.attrs.href}
alt="InVision Embed"
style={{
maxWidth: "100%",
maxHeight: "75vh",
},
}}
shouldRespectMaxDimension
/>
</ImageZoom>
</div>
);
}

View File

@@ -10,13 +10,13 @@ import {
} from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import ImageZoom, { ImageZoom_Image } from "react-medium-image-zoom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { getDataTransferFiles, getEventFiles } from "../../utils/files";
import { sanitizeUrl } from "../../utils/urls";
import { AttachmentValidation } from "../../validations";
import insertFiles, { Options } from "../commands/insertFiles";
import ImageZoom from "../components/ImageZoom";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
import { ComponentProps, Dispatch } from "../types";
@@ -99,7 +99,7 @@ const uploadPlugin = (options: Options) =>
},
});
const IMAGE_CLASSES = ["right-50", "left-50"];
const IMAGE_CLASSES = ["right-50", "left-50", "full-width"];
const imageSizeRegex = /\s=(\d+)?x(\d+)?$/;
type TitleAttributes = {
@@ -362,7 +362,6 @@ export default class Image extends Node {
return (
<ImageComponent
{...props}
view={this.editor.view}
onClick={this.handleSelect(props)}
onDownload={this.handleDownload(props)}
onChangeSize={this.handleChangeSize(props)}
@@ -475,6 +474,19 @@ export default class Image extends Node {
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true;
},
alignFullWidth: () => (state: EditorState, dispatch: Dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const attrs = {
...state.selection.node.attrs,
title: null,
layoutClass: "full-width",
};
const { selection } = state;
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true;
},
replaceImage: () => (state: EditorState) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
@@ -584,9 +596,12 @@ const ImageComponent = (
view: EditorView;
}
) => {
const { theme, isSelected, node, isEditable } = props;
const { alt, src, layoutClass } = node.attrs;
const { isSelected, node, isEditable } = props;
const { src, layoutClass } = node.attrs;
const className = layoutClass ? `image image-${layoutClass}` : "image";
const [contentWidth, setContentWidth] = React.useState(
() => document.body.querySelector("#full-width-container")?.clientWidth || 0
);
const [naturalWidth, setNaturalWidth] = React.useState(node.attrs.width);
const [naturalHeight, setNaturalHeight] = React.useState(node.attrs.height);
const [size, setSize] = React.useState({
@@ -596,8 +611,25 @@ const ImageComponent = (
const [sizeAtDragStart, setSizeAtDragStart] = React.useState(size);
const [offset, setOffset] = React.useState(0);
const [dragging, setDragging] = React.useState<DragDirection>();
const documentWidth = props.view?.dom.clientWidth;
const [documentWidth, setDocumentWidth] = React.useState(
props.view?.dom.clientWidth || 0
);
const maxWidth = layoutClass ? documentWidth / 3 : documentWidth;
const isFullWidth = layoutClass === "full-width";
React.useLayoutEffect(() => {
const handleResize = () => {
const contentWidth =
document.body.querySelector("#full-width-container")?.clientWidth || 0;
setContentWidth(contentWidth);
setDocumentWidth(props.view?.dom.clientWidth || 0);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [props.view]);
const constrainWidth = (width: number) => {
const minWidth = documentWidth * 0.1;
@@ -687,27 +719,33 @@ const ImageComponent = (
};
}, [dragging, handlePointerMove, handlePointerUp]);
const style = { width: size.width || "auto" };
const style = isFullWidth
? { width: contentWidth }
: { width: size.width || "auto" };
return (
<div contentEditable={false} className={className}>
<ImageWrapper
isFullWidth={isFullWidth}
className={isSelected || dragging ? "ProseMirror-selectednode" : ""}
onClick={dragging ? undefined : props.onClick}
style={style}
style={{
...style,
...(isFullWidth
? { marginLeft: -(contentWidth - documentWidth) / 2 }
: {}),
}}
>
{!dragging && size.width > 60 && size.height > 60 && (
<Button onClick={props.onDownload}>
<DownloadIcon color="currentColor" />
</Button>
)}
<ImageZoom
image={
{
style,
src: sanitizeUrl(src) ?? "",
alt,
onLoad: (ev: React.SyntheticEvent<HTMLImageElement>) => {
<ImageZoom zoomMargin={24}>
<img
style={style}
src={sanitizeUrl(src) ?? ""}
onLoad={(ev: React.SyntheticEvent<HTMLImageElement>) => {
// For some SVG's Firefox does not provide the naturalWidth, in this
// rare case we need to provide a default so that the image can be
// seen and is not sized to 0px
@@ -722,17 +760,10 @@ const ImageComponent = (
width: nw,
}));
}
},
} as ImageZoom_Image
}
defaultStyles={{
overlay: {
backgroundColor: theme.background,
},
}}
shouldRespectMaxDimension
/>
{isEditable && (
</ImageZoom>
{isEditable && !isFullWidth && (
<>
<ResizeLeft
onPointerDown={handlePointerDown("left")}
@@ -846,16 +877,23 @@ const Caption = styled.p`
}
`;
const ImageWrapper = styled.div`
const ImageWrapper = styled.div<{ isFullWidth: boolean }>`
line-height: 0;
position: relative;
margin-left: auto;
margin-right: auto;
max-width: 100%;
max-width: ${(props) => (props.isFullWidth ? "initial" : "100%")};
transition-property: width, height;
transition-duration: 150ms;
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
touch-action: none;
overflow: hidden;
img {
transition-property: width, height;
transition-duration: ${(props) => (props.isFullWidth ? "0ms" : "150ms")};
transition-timing-function: ease-in-out;
}
&:hover {
${Button} {

View File

@@ -1,5 +1,6 @@
import { Node as ProsemirrorNode } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import { DefaultTheme } from "styled-components";
@@ -29,6 +30,7 @@ export type MenuItem = {
export type ComponentProps = {
theme: DefaultTheme;
view: EditorView;
node: ProsemirrorNode;
isSelected: boolean;
isEditable: boolean;

View File

@@ -234,6 +234,7 @@
"Align center": "Align center",
"Align left": "Align left",
"Align right": "Align right",
"Full width": "Full width",
"Bulleted list": "Bulleted list",
"Todo list": "Task list",
"Code block": "Code block",
@@ -249,9 +250,6 @@
"Delete image": "Delete image",
"Download image": "Download image",
"Replace image": "Replace image",
"Float left": "Float left",
"Float right": "Float right",
"Center large": "Center large",
"Italic": "Italic",
"Sorry, that link wont work for this embed type": "Sorry, that link wont work for this embed type",
"File attachment": "File attachment",
@@ -313,7 +311,6 @@
"Choose a collection": "Choose a collection",
"Unpublish": "Unpublish",
"Enable embeds": "Enable embeds",
"Full width": "Full width",
"Export options": "Export options",
"Edit group": "Edit group",
"Delete group": "Delete group",

View File

@@ -3318,13 +3318,6 @@
dependencies:
"@types/react" "*"
"@types/react-medium-image-zoom@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/react-medium-image-zoom/-/react-medium-image-zoom-3.0.1.tgz#3c7440edb29515b9d5d3eab808b46d604f8a030b"
integrity sha512-jZejvKxPGOZDiNME3z6Grjex2xTDEXGo+FIrqMTfCClYU4qWR3yOHKI0Hv0C3DzzK99o38CD3bMw6TQ0f5yy4w==
dependencies:
"@types/react" "*"
"@types/react-portal@^4.0.4":
version "4.0.4"
resolved "https://registry.yarnpkg.com/@types/react-portal/-/react-portal-4.0.4.tgz#1c0e5a248f6e18a66f981139c13b6e796f4a92b6"
@@ -11639,10 +11632,10 @@ os-browserify@^0.3.0:
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
outline-icons@^1.45.2:
version "1.45.2"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.45.2.tgz#7249b26af6b58db6a0ba601a5ad5043d18e2841d"
integrity sha512-BClcM9JhfloM5FlrqWFr4i9Kc+n1rdvL9in5O83oH+1nGY/ZMDOcxaP0G+m7ucaltDMBRjEyaXBrNk5vX1Iorw==
outline-icons@^1.46.0:
version "1.46.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.46.0.tgz#04b8f3b1f3b33b0396ebecf30a70d05c90f1f136"
integrity sha512-zC69rYqIHW/vr4IC+2ceAGFYV7artcOTewgduGlNl5Sn+xw8sMdmB1RvIc2ctxd6lxtJRRF5jJ6CRqpo/tM2cg==
oy-vey@^0.12.0:
version "0.12.0"
@@ -12841,11 +12834,6 @@ react-is@^18.0.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
react-medium-image-zoom@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/react-medium-image-zoom/-/react-medium-image-zoom-3.1.3.tgz#b1470abc5a342d65c23021c01bafa8c731821478"
integrity sha512-5CoU8whSCz5Xz2xNeGD34dDfZ6jaf/pybdfZh8HNUmA9mbXbLfj0n6bQWfEUwkq9lsNg1sEkyeIJq2tcvZY8bw==
react-merge-refs@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/react-merge-refs/-/react-merge-refs-2.0.1.tgz#a1f8c2dadefa635333e9b91ec59f30b65228b006"