diff --git a/app/components/Lightbox.tsx b/app/components/Lightbox.tsx index 448dd59a5f..fa4b9bdbc3 100644 --- a/app/components/Lightbox.tsx +++ b/app/components/Lightbox.tsx @@ -2,7 +2,12 @@ import { observer } from "mobx-react"; import * as Dialog from "@radix-ui/react-dialog"; import type { Keyframes } from "styled-components"; import styled, { css, keyframes } from "styled-components"; -import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; +import type { + ComponentProps, + HTMLAttributes, + ReactNode, + SyntheticEvent, +} from "react"; import { createContext, forwardRef, @@ -18,6 +23,7 @@ import { Error as ImageError } from "@shared/editor/components/Image"; import { BackIcon, CloseIcon, + CommentIcon, CrossIcon, DownloadIcon, LinkIcon, @@ -55,6 +61,9 @@ import { NodeSelection } from "prosemirror-state"; import { ImageSource } from "@shared/editor/lib/FileHelper"; import Desktop from "~/utils/Desktop"; import { HStack } from "./primitives/HStack"; +import { useDocumentContext } from "./DocumentContext"; +import LightboxComments from "~/scenes/Document/components/Comments/LightboxComments"; +import { PortalContext } from "./Portal"; export enum LightboxStatus { READY_TO_OPEN, @@ -88,6 +97,15 @@ type Animation = { const ANIMATION_DURATION = 0.3 * Second.ms; +/** + * Stops a React synthetic event from propagating to ancestor handlers, including + * Radix Dialog's outside-interaction detection and the editor's own click + * handlers, so the comments sidebar can manage its own focus. + */ +const stopPropagation = (event: SyntheticEvent) => { + event.stopPropagation(); +}; + type Props = { /** List of allowed images */ images: LightboxImage[]; @@ -225,6 +243,11 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) { const overlayRef = useRef(null); const contentRef = useRef(null); const [status, setStatus] = useState({ lightbox: null, image: null }); + const [commentsOpen, setCommentsOpen] = useState(false); + const [commentsRendered, setCommentsRendered] = useState(false); + const [commentsVisible, setCommentsVisible] = useState(false); + const [commentsPortalEl, setCommentsPortalEl] = + useState(null); const animation = useRef(null); const finalImage = useRef<{ center: { x: number; y: number }; @@ -233,6 +256,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) { } | null>(null); const zoomPanPinchRef = useRef(null); const editor = useEditor(); + const { document: contextDocument } = useDocumentContext(); + const activeNode = editor?.view?.state?.doc?.nodeAt(activeImage.pos); + const canShowComments = + !!contextDocument && activeNode?.type.name === "image"; const currentImageIndex = findIndex( images, @@ -312,6 +339,19 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) { } }, [status.lightbox]); + useEffect(() => { + if (commentsOpen) { + setCommentsRendered(true); + const frame = window.requestAnimationFrame(() => + setCommentsVisible(true) + ); + return () => window.cancelAnimationFrame(frame); + } + setCommentsVisible(false); + const timer = window.setTimeout(() => setCommentsRendered(false), 200); + return () => window.clearTimeout(timer); + }, [commentsOpen]); + useEffect(() => { if (status.image === ImageStatus.MIN_ZOOM) { // It was observed that focus went to `body` as the zoom out button was disabled @@ -441,6 +481,10 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) { status.image === ImageStatus.MAX_ZOOM ) ) { + // Refresh the cached natural image position to account for any layout + // changes (e.g., the comments sidebar opening) since the image loaded. + rememberImagePosition(); + // in lightbox const lightboxImgDOMRect = imgRef.current.getBoundingClientRect(); const { @@ -632,17 +676,30 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) { }, [activeImage, status.lightbox]); const handleKeyDown = (ev: React.KeyboardEvent) => { - ev.preventDefault(); + // Don't intercept keys while typing into an input, textarea, or editor. + const target = ev.target as HTMLElement | null; + if ( + target && + target !== ev.currentTarget && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } switch (ev.key) { case "ArrowLeft": { + ev.preventDefault(); prev(); break; } case "ArrowRight": { + ev.preventDefault(); next(); break; } case "Escape": { + ev.preventDefault(); close(); break; } @@ -698,14 +755,21 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) { onAnimationStart={handleFadeStart} onAnimationEnd={handleFadeEnd} /> - + {t("Lightbox")} {t("View, navigate, or download images in the document")} - + )} - + {canShowComments && ( + + setCommentsOpen((open) => !open)} + aria-label={t("Comments")} + aria-pressed={commentsOpen} + size={32} + icon={} + borderOnHover + neutral + /> + + )} + + - + {currentImageIndex > 0 && !( status.image === ImageStatus.ZOOMED || @@ -878,12 +957,36 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) { status.image === ImageStatus.ZOOMED || status.image === ImageStatus.MAX_ZOOM ) && ( -