feat: Comments sidebar in image lightbox (#12335)

* 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 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-05-17 18:57:19 -04:00
committed by GitHub
parent 1d919cc56a
commit ab3994f3f1
4 changed files with 386 additions and 11 deletions
+173 -11
View File
@@ -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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [status, setStatus] = useState<Status>({ lightbox: null, image: null });
const [commentsOpen, setCommentsOpen] = useState(false);
const [commentsRendered, setCommentsRendered] = useState(false);
const [commentsVisible, setCommentsVisible] = useState(false);
const [commentsPortalEl, setCommentsPortalEl] =
useState<HTMLDivElement | null>(null);
const animation = useRef<Animation | null>(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<ReactZoomPanPinchRef>(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<HTMLDivElement>) => {
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}
/>
<StyledContent onKeyDown={handleKeyDown} ref={contentRef}>
<StyledContent
onKeyDown={handleKeyDown}
ref={contentRef}
$commentsOpen={canShowComments && commentsOpen}
>
<VisuallyHidden.Root>
<Dialog.Title>{t("Lightbox")}</Dialog.Title>
<Dialog.Description>
{t("View, navigate, or download images in the document")}
</Dialog.Description>
</VisuallyHidden.Root>
<Actions animation={animation.current}>
<Actions
animation={animation.current}
$commentsOpen={canShowComments && commentsOpen}
>
<Tooltip content={t("Zoom in")} placement="bottom">
<ActionButton
tabIndex={-1}
@@ -788,7 +852,22 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
)}
<Separator />
{canShowComments && (
<Tooltip content={t("Comments")} placement="bottom">
<ActionButton
tabIndex={-1}
onClick={() => setCommentsOpen((open) => !open)}
aria-label={t("Comments")}
aria-pressed={commentsOpen}
size={32}
icon={<CommentIcon />}
borderOnHover
neutral
/>
</Tooltip>
)}
</Actions>
<CloseAction animation={animation.current}>
<Dialog.Close asChild>
<Tooltip content={t("Close")} shortcut="Esc" placement="bottom">
<ActionButton
@@ -802,7 +881,7 @@ function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
/>
</Tooltip>
</Dialog.Close>
</Actions>
</CloseAction>
{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
) && (
<Nav dir="right" $hidden={isIdle} animation={animation.current}>
<Nav
dir="right"
$hidden={isIdle}
animation={animation.current}
$commentsOpen={canShowComments && commentsOpen}
>
<NavButton onClick={next} size={32} aria-label={t("Next")}>
<NextIcon size={32} />
</NavButton>
</Nav>
)}
{canShowComments && commentsRendered && contextDocument && (
<CommentsSidebar
ref={setCommentsPortalEl}
animation={animation.current}
$open={commentsVisible}
onPointerDown={stopPropagation}
onPointerUp={stopPropagation}
onMouseDown={stopPropagation}
onMouseUp={stopPropagation}
onClick={stopPropagation}
>
<PortalContext.Provider value={commentsPortalEl}>
<LightboxComments
document={contextDocument}
pos={activeImage.pos}
/>
</PortalContext.Provider>
</CommentsSidebar>
)}
</StyledContent>
</Dialog.Portal>
</Dialog.Root>
@@ -1090,7 +1193,7 @@ const StyledImg = styled.img<{
: ""}
`;
const StyledContent = styled(Dialog.Content)`
const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
position: fixed;
inset: 0;
z-index: ${depths.modal};
@@ -1098,6 +1201,8 @@ const StyledContent = styled(Dialog.Content)`
justify-content: center;
align-items: center;
outline: none;
padding-inline-end: ${(props) => (props.$commentsOpen ? "360px" : "0")};
transition: padding-inline-end 200ms ease-out;
`;
const ActionButton = styled(Button)`
@@ -1106,15 +1211,45 @@ const ActionButton = styled(Button)`
const Actions = styled(HStack)<{
animation: Animation | null;
$commentsOpen: boolean;
}>`
position: absolute;
top: 0;
right: 0;
right: ${(props) => (props.$commentsOpen ? "360px" : "44px")};
margin: 16px 12px;
z-index: ${depths.modal};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
transition: right 200ms ease-out;
${(props) =>
props.animation === null
? css`
opacity: 0;
`
: props.animation.fadeIn
? css`
animation: ${props.animation.fadeIn.apply()}
${props.animation.fadeIn.duration}ms;
`
: props.animation.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const CloseAction = styled.div<{ animation: Animation | null }>`
position: fixed;
top: 0;
right: 0;
margin: 16px 12px;
z-index: ${depths.modal + 1};
background: ${(props) => transparentize(0.2, props.theme.background)};
backdrop-filter: blur(4px);
border-radius: 6px;
${(props) =>
props.animation === null
@@ -1138,10 +1273,16 @@ const Nav = styled.div<{
$hidden: boolean;
dir: "left" | "right";
animation: Animation | null;
$commentsOpen?: boolean;
}>`
position: absolute;
${(props) => (props.dir === "left" ? "left: 0;" : "right: 0;")}
transition: opacity 500ms ease-in-out;
${(props) =>
props.dir === "left"
? "left: 0;"
: `right: ${props.$commentsOpen ? "360px" : "0"};`}
transition:
opacity 500ms ease-in-out,
right 200ms ease-out;
z-index: ${depths.modal};
${(props) => props.$hidden && "opacity: 0;"}
${(props) =>
@@ -1183,6 +1324,27 @@ const StyledError = styled(ImageError)<{
: ""}
`;
const CommentsSidebar = styled.div<{
animation: Animation | null;
$open: boolean;
}>`
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: ${depths.modal};
display: flex;
transform: translateX(${(props) => (props.$open ? "0" : "100%")});
transition: transform 200ms ease-out;
${(props) =>
props.animation?.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const NavButton = styled(NudeButton)`
margin: 16px;
opacity: 0.75;
@@ -57,6 +57,11 @@ type Props = {
onBlur?: () => void;
/** Callback when user presses up arrow at the start of the editor */
onUpArrowAtStart?: () => void;
/**
* Callback invoked when a new top-level comment is about to be created,
* just before it is added to the store. Receives the generated comment id.
*/
onBeforeCreate?: (commentId: string) => void;
};
function CommentForm({
@@ -68,6 +73,7 @@ function CommentForm({
onFocus,
onBlur,
onUpArrowAtStart,
onBeforeCreate,
autoFocus,
standalone,
placeholder,
@@ -167,6 +173,9 @@ function CommentForm({
);
comment.id = uuidv4();
if (!thread) {
onBeforeCreate?.(comment.id);
}
comments.add(comment);
comment
@@ -0,0 +1,181 @@
import { observer } from "mobx-react";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { ProsemirrorData, ProsemirrorMark } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import { useDocumentContext } from "~/components/DocumentContext";
import useCurrentUser from "~/hooks/useCurrentUser";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import type Document from "~/models/Document";
import CommentForm from "./CommentForm";
import CommentThread from "./CommentThread";
type Props = {
/** The document the image belongs to. */
document: Document;
/** The position of the image node in the document. */
pos: number;
};
function LightboxComments({ document, pos }: Props) {
const { comments } = useStores();
const { editor, focusedCommentId, setFocusedCommentId } =
useDocumentContext();
const user = useCurrentUser();
const { t } = useTranslation();
const can = usePolicy(document);
const [draft, onSaveDraft] = usePersistedState<ProsemirrorData | undefined>(
`draft-${document.id}-image-${pos}`,
undefined
);
const commentIds = editor?.view?.state?.doc
? ProsemirrorHelper.getCommentIdsAtPos(editor.view.state.doc, pos)
: [];
const threads = comments
.threadsInDocument(document.id)
.filter(
(comment) => commentIds.includes(comment.id) && !comment.isResolved
);
const draftCommentIdRef = useRef<string | null>(null);
// When submitting a new comment from the bottom form, anchor it to the image
// by adding a draft comment mark to the image node's `attrs.marks`. The mark
// is flipped to `draft: false` in `handleSubmit` once the comment has been
// persisted on the server.
const handleBeforeCreate = useCallback(
(commentId: string) => {
if (!editor) {
return;
}
const { state, dispatch } = editor.view;
const node = state.doc.nodeAt(pos);
if (!node) {
return;
}
const existingMarks = (node.attrs.marks ?? []) as ProsemirrorMark[];
const newMark: ProsemirrorMark = {
type: "comment",
attrs: {
id: commentId,
userId: user.id,
draft: true,
},
};
const newAttrs = {
...node.attrs,
marks: [...existingMarks, newMark],
};
dispatch(state.tr.setNodeMarkup(pos, undefined, newAttrs));
draftCommentIdRef.current = commentId;
},
[editor, pos, user.id]
);
const handleSubmit = useCallback(() => {
setFocusedCommentId(null);
const commentId = draftCommentIdRef.current;
if (commentId) {
editor?.updateComment(commentId, { draft: false });
draftCommentIdRef.current = null;
}
}, [editor, setFocusedCommentId]);
const focusedComment =
focusedCommentId && commentIds.includes(focusedCommentId)
? comments.get(focusedCommentId)
: undefined;
const hasComments = threads.length > 0;
const canComment = can.comment;
return (
<Wrapper column>
<Header>{t("Comments")}</Header>
<Body bottomShadow={!focusedComment} hiddenScrollbars topShadow>
<List $hasComments={hasComments}>
{hasComments ? (
threads.map((thread) => (
<CommentThread
key={thread.id}
comment={thread}
document={document}
recessed={!!focusedComment && focusedComment.id !== thread.id}
focused={focusedComment?.id === thread.id}
/>
))
) : (
<NoComments align="center" justify="center" auto>
<Empty>{t("No comments yet")}</Empty>
</NoComments>
)}
</List>
</Body>
{canComment && !focusedComment && (
<NewCommentForm
draft={draft}
onSaveDraft={onSaveDraft}
documentId={document.id}
placeholder={`${t("Add a comment")}`}
autoFocus={false}
standalone
onBeforeCreate={handleBeforeCreate}
onSubmit={handleSubmit}
/>
)}
</Wrapper>
);
}
const Wrapper = styled(Flex)`
width: 360px;
max-width: 100%;
height: 100%;
background: ${s("background")};
border-inline-start: 1px solid ${s("divider")};
`;
const Header = styled.div`
flex-shrink: 0;
padding: 20px 16px 16px;
font-size: 16px;
font-weight: 600;
color: ${s("text")};
user-select: none;
`;
const Body = styled(Scrollable)`
flex: 1 1 auto;
min-height: 0;
`;
const List = styled.div<{ $hasComments: boolean }>`
height: ${(props) => (props.$hasComments ? "auto" : "100%")};
padding-bottom: 12px;
`;
const NoComments = styled(Flex)`
height: 100%;
padding: 24px;
`;
const NewCommentForm = styled(CommentForm)`
flex-shrink: 0;
padding: 12px;
padding-inline-end: 18px;
padding-inline-start: 12px;
border-top: 1px solid ${s("divider")};
`;
export default observer(LightboxComments);
+23
View File
@@ -300,6 +300,29 @@ export class ProsemirrorHelper {
];
}
/**
* Returns the ids of comment marks attached to the node at the given position.
*
* @param doc Prosemirror document node.
* @param pos Position of the node within the document.
* @returns array of comment ids anchored to the node.
*/
static getCommentIdsAtPos(doc: Node, pos: number): string[] {
const node = doc.nodeAt(pos);
if (!node || !Array.isArray(node.attrs?.marks)) {
return [];
}
return (
node.attrs.marks as { type?: string; attrs?: { id?: string } }[]
)
.filter(
(mark): mark is { type: "comment"; attrs: { id: string } } =>
mark?.type === "comment" && !!mark?.attrs?.id
)
.map((mark) => mark.attrs.id);
}
/**
* Builds the consolidated anchor text for the given comment-id.
*