From 23606dad1d1421f017bd1c6edb70f2bda49777c4 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 3 Jun 2024 20:26:25 -0400 Subject: [PATCH] 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 --- .jestconfig.json | 12 +- __mocks__/react-medium-image-zoom.js | 1 + app/editor/index.tsx | 7 + package.json | 1 + shared/editor/components/Image.tsx | 4 +- shared/editor/components/ImageZoom.tsx | 161 +++++ .../components/ImageZoom/Controlled.tsx | 584 ------------------ shared/editor/components/ImageZoom/LICENSE | 30 - shared/editor/components/ImageZoom/Styles.tsx | 61 -- shared/editor/components/ImageZoom/index.tsx | 19 - shared/editor/components/ImageZoom/types.ts | 5 - shared/editor/components/ImageZoom/utils.ts | 553 ----------------- shared/editor/embeds/InVision.tsx | 4 +- shared/utils/ProsemirrorHelper.ts | 20 + yarn.lock | 5 + 15 files changed, 207 insertions(+), 1260 deletions(-) create mode 100644 __mocks__/react-medium-image-zoom.js create mode 100644 shared/editor/components/ImageZoom.tsx delete mode 100644 shared/editor/components/ImageZoom/Controlled.tsx delete mode 100644 shared/editor/components/ImageZoom/LICENSE delete mode 100644 shared/editor/components/ImageZoom/Styles.tsx delete mode 100644 shared/editor/components/ImageZoom/index.tsx delete mode 100644 shared/editor/components/ImageZoom/types.ts delete mode 100644 shared/editor/components/ImageZoom/utils.ts diff --git a/.jestconfig.json b/.jestconfig.json index 2e8ae9004..c45e43e75 100644 --- a/.jestconfig.json +++ b/.jestconfig.json @@ -7,7 +7,8 @@ "roots": ["/server", "/plugins"], "moduleNameMapper": { "^@server/(.*)$": "/server/$1", - "^@shared/(.*)$": "/shared/$1" + "^@shared/(.*)$": "/shared/$1", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, "setupFiles": ["/__mocks__/console.js"], "setupFilesAfterEnv": ["/server/test/setup.ts"], @@ -22,7 +23,8 @@ "^~/(.*)$": "/app/$1", "^@shared/(.*)$": "/shared/$1", "^.*[.](gif|ttf|eot|svg)$": "/__test__/fileMock.js", - "^uuid$": "/node_modules/uuid/dist/index.js" + "^uuid$": "/node_modules/uuid/dist/index.js", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, "modulePaths": ["/app"], "setupFiles": ["/__mocks__/window.js"], @@ -37,7 +39,8 @@ "roots": ["/shared"], "moduleNameMapper": { "^@server/(.*)$": "/server/$1", - "^@shared/(.*)$": "/shared/$1" + "^@shared/(.*)$": "/shared/$1", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, "setupFiles": ["/__mocks__/console.js"], "setupFilesAfterEnv": ["/shared/test/setup.ts"], @@ -50,7 +53,8 @@ "^~/(.*)$": "/app/$1", "^@shared/(.*)$": "/shared/$1", "^.*[.](gif|ttf|eot|svg)$": "/__test__/fileMock.js", - "^uuid$": "/node_modules/uuid/dist/index.js" + "^uuid$": "/node_modules/uuid/dist/index.js", + "react-medium-image-zoom": "/__mocks__/react-medium-image-zoom.js" }, "setupFiles": ["/__mocks__/window.js"], "testEnvironment": "jsdom", diff --git a/__mocks__/react-medium-image-zoom.js b/__mocks__/react-medium-image-zoom.js new file mode 100644 index 000000000..7646bbd17 --- /dev/null +++ b/__mocks__/react-medium-image-zoom.js @@ -0,0 +1 @@ +export default null; diff --git a/app/editor/index.tsx b/app/editor/index.tsx index 9dc83132e..5f7bed11d 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -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. * diff --git a/package.json b/package.json index 4fd112f68..73b6220b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/editor/components/Image.tsx b/shared/editor/components/Image.tsx index 34b378325..ff548d850 100644 --- a/shared/editor/components/Image.tsx +++ b/shared/editor/components/Image.tsx @@ -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) => { )} - + { + const Zoom = React.lazy(() => import("react-medium-image-zoom")); + const [isActivated, setIsActivated] = React.useState(false); + + const handleActivated = React.useCallback(() => { + setIsActivated(true); + }, []); + + const fallback = ( + + {children} + + ); + + if (!isActivated) { + return fallback; + } + + return ( + + + } + > +
{children}
+
+
+ ); +}; + +const Lightbox = ({ + caption, + modalState, + img, +}: { + caption: string | undefined; + modalState: string; + img: React.ReactNode; +}) => ( +
+ {img} + {caption} +
+); + +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; + } + } +`; diff --git a/shared/editor/components/ImageZoom/Controlled.tsx b/shared/editor/components/ImageZoom/Controlled.tsx deleted file mode 100644 index ffd73982e..000000000 --- a/shared/editor/components/ImageZoom/Controlled.tsx +++ /dev/null @@ -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; - 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( - '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) => { - 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 deleted file mode 100644 index c88d1420a..000000000 --- a/shared/editor/components/ImageZoom/LICENSE +++ /dev/null @@ -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. \ No newline at end of file diff --git a/shared/editor/components/ImageZoom/Styles.tsx b/shared/editor/components/ImageZoom/Styles.tsx deleted file mode 100644 index 0ae854169..000000000 --- a/shared/editor/components/ImageZoom/Styles.tsx +++ /dev/null @@ -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; - } - } -`; diff --git a/shared/editor/components/ImageZoom/index.tsx b/shared/editor/components/ImageZoom/index.tsx deleted file mode 100644 index 7fb343d33..000000000 --- a/shared/editor/components/ImageZoom/index.tsx +++ /dev/null @@ -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 ( - <> - - - - ); -} diff --git a/shared/editor/components/ImageZoom/types.ts b/shared/editor/components/ImageZoom/types.ts deleted file mode 100644 index 801bff8d5..000000000 --- a/shared/editor/components/ImageZoom/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type SupportedImage = - | HTMLImageElement - | HTMLDivElement - | HTMLSpanElement - | SVGElement; diff --git a/shared/editor/components/ImageZoom/utils.ts b/shared/editor/components/ImageZoom/utils.ts deleted file mode 100644 index 30d8ff07d..000000000 --- a/shared/editor/components/ImageZoom/utils.ts +++ /dev/null @@ -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; -} diff --git a/shared/editor/embeds/InVision.tsx b/shared/editor/embeds/InVision.tsx index d749945ee..57d8f96f9 100644 --- a/shared/editor/embeds/InVision.tsx +++ b/shared/editor/embeds/InVision.tsx @@ -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 (
- + InVision Embed 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. * diff --git a/yarn.lock b/yarn.lock index 70b06bac0..84c4d8b36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"