diff --git a/app/editor/components/ComponentView.tsx b/app/editor/components/ComponentView.tsx index ccf51b1fa..d9dda7ee4 100644 --- a/app/editor/components/ComponentView.tsx +++ b/app/editor/components/ComponentView.tsx @@ -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, diff --git a/app/editor/menus/image.tsx b/app/editor/menus/image.tsx index 0518addd4..afea677a0 100644 --- a/app/editor/menus/image.tsx +++ b/app/editor/menus/image.tsx @@ -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: , + visible: true, + active: isFullWidthAligned, + }, { name: "separator", visible: true, diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index c970ff810..5b9196de5 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -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 won’t work for this embed type"), file: t("File attachment"), diff --git a/app/scenes/Document/components/Contents.tsx b/app/scenes/Document/components/Contents.tsx index d7eb30584..d3e43c33c 100644 --- a/app/scenes/Document/components/Contents.tsx +++ b/app/scenes/Document/components/Contents.tsx @@ -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}; } diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 091a7b027..d8ad3f033 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -481,7 +481,12 @@ class DocumentScene extends React.Component { } }} /> - + ( @@ -562,7 +567,7 @@ class DocumentScene extends React.Component { > }> - + {revision ? ( { /> ) : ( <> - {showContents && ( - - )} { )} + + {showContents && ( + + )} )} diff --git a/app/scenes/Document/components/MultiplayerEditor.tsx b/app/scenes/Document/components/MultiplayerEditor.tsx index c93a1c06b..7ff74a702 100644 --- a/app/scenes/Document/components/MultiplayerEditor.tsx +++ b/app/scenes/Document/components/MultiplayerEditor.tsx @@ -287,7 +287,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) { style={ showCache ? { - display: "none", + opacity: 0, + pointerEvents: "none", } : undefined } diff --git a/package.json b/package.json index 926b0f6ef..a7de38c39 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/editor/components/ImageZoom/Controlled.tsx b/shared/editor/components/ImageZoom/Controlled.tsx new file mode 100644 index 000000000..ca5da561f --- /dev/null +++ b/shared/editor/components/ImageZoom/Controlled.tsx @@ -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; + zoomMargin?: number; +} + +export function Controlled(props: ControlledProps) { + return ; +} + +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(); + private refDialog = createRef(); + private refModalContent = createRef(); + private refModalImg = createRef(); + private refWrap = createRef(); + + 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 ? ( + {imgAlt} + ) : isSvg ? ( +
+ ) : null; + + modalContent = ZoomContent ? ( + + ) : ( + modalImg + ); + } + + // ========================================================================= + + return ( + + + {children} + + {hasImage && + elDialogContainer !== null && + createPortal( + +
+
+ {modalContent} + + + +
+
, + elDialogContainer + )} +
+ ); + } + + 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) => { + 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) => { + 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); + } + }; +} diff --git a/shared/editor/components/ImageZoom/LICENSE b/shared/editor/components/ImageZoom/LICENSE new file mode 100644 index 000000000..c88d1420a --- /dev/null +++ b/shared/editor/components/ImageZoom/LICENSE @@ -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. \ No newline at end of file diff --git a/shared/editor/components/ImageZoom/Styles.tsx b/shared/editor/components/ImageZoom/Styles.tsx new file mode 100644 index 000000000..6d8169930 --- /dev/null +++ b/shared/editor/components/ImageZoom/Styles.tsx @@ -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; + } + } +`; diff --git a/shared/editor/components/ImageZoom/index.tsx b/shared/editor/components/ImageZoom/index.tsx new file mode 100644 index 000000000..7fb343d33 --- /dev/null +++ b/shared/editor/components/ImageZoom/index.tsx @@ -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 ( + <> + + + + ); +} diff --git a/shared/editor/components/ImageZoom/types.ts b/shared/editor/components/ImageZoom/types.ts new file mode 100644 index 000000000..801bff8d5 --- /dev/null +++ b/shared/editor/components/ImageZoom/types.ts @@ -0,0 +1,5 @@ +export type SupportedImage = + | HTMLImageElement + | HTMLDivElement + | HTMLSpanElement + | SVGElement; diff --git a/shared/editor/components/ImageZoom/utils.ts b/shared/editor/components/ImageZoom/utils.ts new file mode 100644 index 000000000..dd213501b --- /dev/null +++ b/shared/editor/components/ImageZoom/utils.ts @@ -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; +} diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 876718534..73124c4dc 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -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; } diff --git a/shared/editor/embeds/InVision.tsx b/shared/editor/embeds/InVision.tsx index bac9f87da..a5e259fbd 100644 --- a/shared/editor/embeds/InVision.tsx +++ b/shared/editor/embeds/InVision.tsx @@ -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 ( - +
+ + InVision Embed + +
); } diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index a88932e70..b8dde6fff 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -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 ( (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(); - 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,52 +719,51 @@ const ImageComponent = ( }; }, [dragging, handlePointerMove, handlePointerUp]); - const style = { width: size.width || "auto" }; + const style = isFullWidth + ? { width: contentWidth } + : { width: size.width || "auto" }; return (
{!dragging && size.width > 60 && size.height > 60 && ( )} - ) => { - // 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 - const nw = (ev.target as HTMLImageElement).naturalWidth || 300; - const nh = (ev.target as HTMLImageElement).naturalHeight; - setNaturalWidth(nw); - setNaturalHeight(nh); + + ) => { + // 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 + const nw = (ev.target as HTMLImageElement).naturalWidth || 300; + const nh = (ev.target as HTMLImageElement).naturalHeight; + setNaturalWidth(nw); + setNaturalHeight(nh); - if (!node.attrs.width) { - setSize((state) => ({ - ...state, - width: nw, - })); - } - }, - } as ImageZoom_Image - } - defaultStyles={{ - overlay: { - backgroundColor: theme.background, - }, - }} - shouldRespectMaxDimension - /> - {isEditable && ( + if (!node.attrs.width) { + setSize((state) => ({ + ...state, + width: nw, + })); + } + }} + /> + + {isEditable && !isFullWidth && ( <> ` 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} { diff --git a/shared/editor/types/index.ts b/shared/editor/types/index.ts index 6b75544ef..274c95f28 100644 --- a/shared/editor/types/index.ts +++ b/shared/editor/types/index.ts @@ -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; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 0f1e8b11e..ae49cc813 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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 won’t work for this embed type": "Sorry, that link won’t 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", diff --git a/yarn.lock b/yarn.lock index 8e563d9ec..c1885186a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"