From ab3994f3f12d27484d6b161a486a254a966358b7 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 17 May 2026 18:57:19 -0400 Subject: [PATCH] feat: Comments sidebar in image lightbox (#12335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Toggle comments sidebar in editor lightbox Adds a new comments toggle button to the lightbox top-right actions. When toggled the sidebar slides out on the right and shows only the threads anchored to the active image node. A new comment form at the bottom creates a thread anchored to the image via a comment mark on the node. https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7 * fix: Make lightbox comments sidebar interactable The sidebar was being rendered as a sibling of Dialog.Content, so Radix's focus/click-outside trap blocked all interaction with it. Move it inside Dialog.Content so clicks and focus stay within the dialog. Also scope the lightbox handleKeyDown to only preventDefault and act on arrow/escape keys — and bail out entirely when typing into an input, textarea, or contenteditable so the comment form receives keystrokes. https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7 * fix: Align lightbox comments header with action buttons Nudge the sidebar Comments heading 4px down so its baseline lines up with the lightbox top-right action bar. https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7 * fix: Render lightbox sidebar popovers inside the dialog Reactions, menus, and tooltips inside the lightbox comments sidebar were portalling into the editor wrapper via PortalContext — which is hidden behind the lightbox overlay. Provide a PortalContext that targets the sidebar element itself so popovers render inside the dialog and remain visible. https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7 * fix: Prevent lightbox handlers from stealing focus from reply input Pointer events bubbling out of the comments sidebar were reaching the ancestor Dialog.Content / lightbox handlers and somehow disrupting focus on the ProseMirror reply input. Stop propagation of pointer, mouse, and click events at the CommentsSidebar so the sidebar owns its own interaction handling. https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7 * fix: Anchor lightbox close animation to current image position The close animation's translation was calculated relative to the image position cached when the image first loaded — before the comments sidebar could shift the image left. Recapture the natural position at the start of setupZoomOut so the animation correctly starts where the image actually is when the sidebar is open. https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7 * fix: Fade the comments sidebar with the rest of the lightbox The sidebar previously had only a slide-in animation on mount and stayed fully opaque while the rest of the lightbox faded out on close. Wire the sidebar to the shared fadeOut animation so it disappears in lockstep with the overlay and action controls. https://claude.ai/code/session_01W3duHkZJ6vgNPCQJL8hQK7 * Final fixes --------- Co-authored-by: Claude --- app/components/Lightbox.tsx | 184 ++++++++++++++++-- .../components/Comments/CommentForm.tsx | 9 + .../components/Comments/LightboxComments.tsx | 181 +++++++++++++++++ shared/utils/ProsemirrorHelper.ts | 23 +++ 4 files changed, 386 insertions(+), 11 deletions(-) create mode 100644 app/scenes/Document/components/Comments/LightboxComments.tsx 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 ) && ( -