Files
Tom Moor ab3994f3f1 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>
2026-05-17 18:57:19 -04:00

1361 lines
37 KiB
TypeScript

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,
SyntheticEvent,
} from "react";
import {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { isInternalUrl } from "@shared/utils/urls";
import { Error as ImageError } from "@shared/editor/components/Image";
import {
BackIcon,
CloseIcon,
CommentIcon,
CrossIcon,
DownloadIcon,
LinkIcon,
NextIcon,
ZoomInIcon,
ZoomOutIcon,
EditIcon,
} from "outline-icons";
import { depths, extraArea, s } from "@shared/styles";
import NudeButton from "./NudeButton";
import useIdle from "~/hooks/useIdle";
import { Second } from "@shared/utils/time";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { useTranslation } from "react-i18next";
import Tooltip from "~/components/Tooltip";
import LoadingIndicator from "./LoadingIndicator";
import Fade from "./Fade";
import Button from "./Button";
import CopyToClipboard from "./CopyToClipboard";
import { Separator } from "./Actions";
import useSwipe from "~/hooks/useSwipe";
import { toast } from "sonner";
import { findIndex } from "es-toolkit/compat";
import type { LightboxImage } from "@shared/editor/lib/Lightbox";
import type { ReactZoomPanPinchRef } from "react-zoom-pan-pinch";
import {
TransformWrapper,
TransformComponent,
useTransformEffect,
} from "react-zoom-pan-pinch";
import { transparentize } from "polished";
import { mergeRefs } from "react-merge-refs";
import { useEditor } from "~/editor/components/EditorContext";
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,
OPENING,
OPENED,
READY_TO_CLOSE,
CLOSING,
CLOSED,
}
export enum ImageStatus {
LOADING,
ERROR,
LOADED,
MIN_ZOOM,
MAX_ZOOM,
ZOOMED,
}
type Status = {
lightbox: LightboxStatus | null;
image: ImageStatus | null;
};
type Animation = {
fadeIn?: { apply: () => Keyframes; duration: number };
fadeOut?: { apply: () => Keyframes; duration: number };
zoomIn?: { apply: () => Keyframes; duration: number };
zoomOut?: { apply: () => Keyframes; duration: number };
startTime?: number;
};
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[];
/** The currently active image in the document */
activeImage: LightboxImage;
/** Callback triggered when the active image is updated */
onUpdate: (activeImage: LightboxImage | null) => void;
/** Callback triggered when Lightbox closes */
onClose: () => void;
/** Whether the editor is read only */
readOnly?: boolean;
};
const ZoomPanPinchContext = createContext({ isImagePanning: false });
type ZoomablePannablePinchableProps = {
children: ReactNode;
panningDisabled: boolean;
disabled: boolean;
onClose?: () => void;
};
const ZoomablePannablePinchable = forwardRef<
ReactZoomPanPinchRef,
ZoomablePannablePinchableProps
>(({ children, panningDisabled, disabled, onClose }, ref) => {
const { isPanning, ...panningHandlers } = usePanning();
const wrapperRef = useRef<ReactZoomPanPinchRef>(null);
const scale = wrapperRef.current?.instance.transformState.scale ?? 1;
const wrapperProps = useMemo(
() =>
({
onClick: (event) => {
if (scale > 1) {
return;
}
if (event.defaultPrevented) {
return;
}
if (
["IMG", "INPUT", "BUTTON", "A"].includes(
(event.target as Element).tagName
)
) {
return;
}
onClose?.();
},
}) satisfies HTMLAttributes<HTMLDivElement>,
[onClose, scale]
);
return (
<ZoomPanPinchContext.Provider value={{ isImagePanning: isPanning }}>
<TransformWrapper
ref={mergeRefs([ref, wrapperRef])}
disabled={disabled}
doubleClick={{ disabled: true }}
minScale={1}
maxScale={8}
panning={{
disabled: panningDisabled,
}}
{...panningHandlers}
>
<TransformComponent
wrapperStyle={{
width: "100%",
height: "100%",
cursor: isPanning ? "grabbing" : scale > 1 ? "grab" : "zoom-out",
}}
contentStyle={{
width: "100%",
height: "100%",
padding: "56px",
justifyContent: "center",
alignItems: "center",
}}
wrapperProps={wrapperProps}
>
{children}
</TransformComponent>
</TransformWrapper>
</ZoomPanPinchContext.Provider>
);
});
function usePanning() {
const [isPanning, setPanning] = useState(false);
const dragged = useRef(false);
const onPanningStart: ComponentProps<
typeof TransformWrapper
>["onPanningStart"] = (ref) => {
const zoomedIn = ref.state.scale > 1;
if (zoomedIn) {
setPanning(ref.instance.isPanning);
}
};
const onPanning: ComponentProps<
typeof TransformWrapper
>["onPanning"] = () => {
dragged.current = true;
};
const onPanningStop: ComponentProps<
typeof TransformWrapper
>["onPanningStop"] = (ref, event) => {
setPanning(ref.instance.isPanning);
if (dragged.current) {
dragged.current = false;
} else if (event.target instanceof HTMLImageElement) {
const zoomedOut = Math.abs(ref.state.scale - 1) < 0.001;
if (zoomedOut) {
ref.zoomIn();
} else {
ref.resetTransform();
}
}
};
return {
isPanning,
onPanningStart,
onPanning,
onPanningStop,
};
}
function Lightbox({ images, activeImage, onUpdate, onClose, readOnly }: Props) {
const isIdle = useIdle(3 * Second.ms);
const { t } = useTranslation();
const imgRef = useRef<HTMLImageElement | null>(null);
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 };
width: number;
height: number;
} | 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,
(img) => img.pos === activeImage.pos
);
// Debugging status changes
// useEffect(() => {
// console.log(
// `lstat:${status.lightbox === null ? status.lightbox : LightboxStatus[status.lightbox]}, istat:${status.image === null ? status.image : ImageStatus[status.image]}`
// );
// }, [status]);
useEffect(
() => () => {
if (status.lightbox === LightboxStatus.CLOSED) {
onClose();
}
},
[status.lightbox]
);
useEffect(() => {
setStatus({
lightbox: LightboxStatus.READY_TO_OPEN,
image: status.image,
});
}, []);
useEffect(() => {
if (status.image === ImageStatus.LOADED) {
rememberImagePosition();
}
}, [status.image]);
useEffect(() => {
if (
(status.image === ImageStatus.ERROR ||
status.image === ImageStatus.LOADED) &&
status.lightbox === LightboxStatus.READY_TO_OPEN
) {
setupFadeIn();
setupZoomIn();
setStatus({
lightbox: LightboxStatus.OPENING,
image: status.image,
});
}
}, [status.image, status.lightbox]);
useEffect(() => {
if (
status.lightbox === LightboxStatus.OPENED &&
status.image === ImageStatus.LOADED
) {
setStatus({
lightbox: LightboxStatus.OPENED,
image: ImageStatus.MIN_ZOOM,
});
}
}, [status.lightbox, status.image]);
useEffect(() => {
if (status.lightbox === LightboxStatus.READY_TO_CLOSE) {
setupFadeOut();
setupZoomOut();
setStatus({
lightbox: LightboxStatus.CLOSING,
image: status.image,
});
}
}, [status.lightbox]);
useEffect(() => {
if (status.lightbox === LightboxStatus.CLOSED) {
onUpdate(null);
}
}, [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
// upon clicking it. This stopped navigating to next/previous image using arrow keys.
// So focusing the content div here to restore the functionality.
contentRef.current?.focus();
}
}, [status.image]);
const rememberImagePosition = () => {
if (imgRef.current) {
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
top: lightboxImgTop,
left: lightboxImgLeft,
width: lightboxImgWidth,
height: lightboxImgHeight,
} = lightboxImgDOMRect;
finalImage.current = {
center: {
x: lightboxImgLeft + lightboxImgWidth / 2,
y: lightboxImgTop + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
}
};
const setupZoomIn = () => {
if (imgRef.current) {
// in editor
const editorImageEl = activeImage.getElement();
if (!editorImageEl) {
return;
}
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
left: editorImgLeft,
width: editorImgWidth,
height: editorImgHeight,
} = editorImgDOMRect;
const from = {
center: {
x: editorImgLeft + editorImgWidth / 2,
y: editorImgTop + editorImgHeight / 2,
},
width: editorImgWidth,
height: editorImgHeight,
};
// in lightbox
const lightboxImgDOMRect = imgRef.current.getBoundingClientRect();
const {
top: lightboxImgTop,
left: lightboxImgLeft,
width: lightboxImgWidth,
height: lightboxImgHeight,
} = lightboxImgDOMRect;
const to = {
center: {
x: lightboxImgLeft + lightboxImgWidth / 2,
y: lightboxImgTop + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
const zoomIn = () => {
const tx = from.center.x - to.center.x;
const ty = from.center.y - to.center.y;
return keyframes`
from {
translate: ${tx}px ${ty}px;
scale: ${from.width / to.width};
}
to {
translate: 0;
scale: 1;
}
`;
};
animation.current = {
...(animation.current ?? {}),
zoomOut: undefined,
zoomIn: { apply: zoomIn, duration: ANIMATION_DURATION },
};
}
};
const setupFadeIn = () => {
const fadeIn = () => keyframes`
from { opacity: 0; }
to { opacity: 1; }
`;
animation.current = {
...(animation.current ?? {}),
fadeIn: { apply: fadeIn, duration: ANIMATION_DURATION },
fadeOut: undefined,
};
};
const setupFadeOut = () => {
const fadeOut = () => keyframes`
from { opacity: ${overlayRef.current ? window.getComputedStyle(overlayRef.current).opacity : 1}; }
to { opacity: 0; }
`;
animation.current = {
...(animation.current ?? {}),
fadeIn: undefined,
fadeOut: {
apply: fadeOut,
duration: animation.current?.startTime
? Date.now() - animation.current.startTime
: ANIMATION_DURATION,
},
};
};
const setupZoomOut = () => {
if (
imgRef.current &&
!(
status.image === ImageStatus.ZOOMED ||
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 {
top: lightboxImgTop,
left: lightboxImgLeft,
width: lightboxImgWidth,
height: lightboxImgHeight,
} = lightboxImgDOMRect;
const from = {
center: {
x: lightboxImgLeft + lightboxImgWidth / 2,
y: lightboxImgTop + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
// in editor
const editorImageEl = activeImage.getElement();
let to;
if (editorImageEl?.isConnected) {
const editorImgDOMRect = editorImageEl.getBoundingClientRect();
const {
top: editorImgTop,
left: editorImgLeft,
width: editorImgWidth,
height: editorImgHeight,
} = editorImgDOMRect;
to = {
center: {
x: editorImgLeft + editorImgWidth / 2,
y:
editorImgTop + editorImgHeight / 2 >
window.innerHeight + editorImgHeight / 2
? window.innerHeight + editorImgHeight / 2
: editorImgTop + editorImgHeight / 2 < -editorImgHeight / 2
? -editorImgHeight / 2
: editorImgTop + editorImgHeight / 2,
},
width: editorImgWidth,
height: editorImgHeight,
};
} else {
to = {
center: {
x: from.center.x,
y: window.innerHeight + lightboxImgHeight / 2,
},
width: lightboxImgWidth,
height: lightboxImgHeight,
};
}
const zoomOut = () => {
const final = finalImage.current;
if (!final) {
return keyframes``;
}
const fromTx = from.center.x - final.center.x;
const fromTy = from.center.y - final.center.y;
const toTx = to.center.x - final.center.x;
const toTy = to.center.y - final.center.y;
const fromS = from.width / final.width;
const toS = to.width / final.width;
return keyframes`
from {
translate: ${fromTx}px ${fromTy}px;
scale: ${fromS};
}
to {
translate: ${toTx}px ${toTy}px;
scale: ${toS};
}
`;
};
animation.current = {
...(animation.current ?? {}),
zoomIn: undefined,
zoomOut: {
apply: zoomOut,
duration: animation.current?.startTime
? Date.now() - animation.current.startTime
: ANIMATION_DURATION,
},
};
}
};
const prev = () => {
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const prevIndex = currentImageIndex - 1;
if (prevIndex < 0) {
return;
}
onUpdate(images[prevIndex]);
}
};
const next = () => {
if (
status.lightbox === LightboxStatus.OPENED &&
(status.image === ImageStatus.MIN_ZOOM ||
status.image === ImageStatus.ERROR)
) {
const nextIndex = currentImageIndex + 1;
if (nextIndex >= images.length) {
return;
}
onUpdate(images[nextIndex]);
}
};
const close = () => {
if (
status.lightbox === LightboxStatus.OPENING ||
status.lightbox === LightboxStatus.OPENED
) {
setStatus({
lightbox: LightboxStatus.READY_TO_CLOSE,
image: status.image,
});
}
};
const svgDataURLToBlob = (dataURL: string) => {
// Match the SVG data URL format (with or without charset)
const match = dataURL.match(
/^data:image\/svg\+xml(?:;charset=utf-8)?,(.*)$/i
);
if (!match) {
return;
}
const encodedSVGData = match[1];
const decodedSVGData = decodeURIComponent(encodedSVGData);
// Convert string to Uint8Array
const uint8 = new Uint8Array(decodedSVGData.length);
for (let i = 0; i < decodedSVGData.length; ++i) {
uint8[i] = decodedSVGData.charCodeAt(i);
}
// Create and return the Blob
return new Blob([uint8], { type: "image/svg+xml" });
};
const downloadImage = async (src: string, saveAs: string) => {
let imageBlob;
if (isInternalUrl(src)) {
const image = await fetch(src);
imageBlob = await image.blob();
} else {
// Assuming it's a mermaid svg
imageBlob = svgDataURLToBlob(src);
}
if (!imageBlob) {
toast.error(t("Unable to download image"));
return;
}
const imageURL = URL.createObjectURL(imageBlob);
const name = saveAs || "image";
const extension = imageBlob.type.split(/\/|\+/g)[1];
// create a temporary link node and click it with our image data
const link = document.createElement("a");
link.href = imageURL;
link.download = `${name}.${extension}`;
document.body.appendChild(link);
link.click();
// cleanup
document.body.removeChild(link);
URL.revokeObjectURL(imageURL);
};
const handleDownload = useCallback(() => {
if (activeImage && status.lightbox === LightboxStatus.OPENED) {
void downloadImage(activeImage.src, activeImage.alt);
}
}, [activeImage, status.lightbox]);
const handleKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
// 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;
}
}
};
const handleFadeStart = () => {
if (animation.current?.fadeIn) {
animation.current = {
...(animation.current ?? {}),
startTime: Date.now(),
};
}
};
const handleFadeEnd = () => {
if (animation.current?.fadeIn) {
animation.current = {
...(animation.current ?? {}),
zoomIn: undefined,
fadeIn: undefined,
startTime: undefined,
};
setStatus({
lightbox: LightboxStatus.OPENED,
image: status.image,
});
} else if (animation.current?.fadeOut) {
setStatus({
lightbox: LightboxStatus.CLOSED,
image: null,
});
}
};
const handleEditDiagram = () => {
const { state, dispatch } = editor.view;
// Select the node at the position
const tr = state.tr.setSelection(
NodeSelection.create(state.doc, activeImage.pos)
);
dispatch(tr);
editor.commands.editDiagram();
};
return (
<Dialog.Root open={true}>
<Dialog.Portal>
<StyledOverlay
ref={overlayRef}
animation={animation.current}
onAnimationStart={handleFadeStart}
onAnimationEnd={handleFadeEnd}
/>
<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}
$commentsOpen={canShowComments && commentsOpen}
>
<Tooltip content={t("Zoom in")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
status.image === ImageStatus.MAX_ZOOM ||
status.image === ImageStatus.ERROR
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomIn();
}
}}
aria-label={t("Zoom in")}
size={32}
icon={<ZoomInIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Tooltip content={t("Zoom out")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
}
onClick={() => {
if (zoomPanPinchRef.current) {
zoomPanPinchRef.current.zoomOut();
}
}}
aria-label={t("Zoom out")}
size={32}
icon={<ZoomOutIcon />}
borderOnHover
neutral
/>
</Tooltip>
<Separator />
<Tooltip content={t("Copy link")} placement="bottom">
<CopyToClipboard text={imgRef.current?.src ?? ""}>
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
aria-label={t("Copy link")}
size={32}
icon={<LinkIcon />}
borderOnHover
neutral
/>
</CopyToClipboard>
</Tooltip>
<Tooltip content={t("Download")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={handleDownload}
aria-label={t("Download")}
size={32}
icon={<DownloadIcon />}
borderOnHover
neutral
/>
</Tooltip>
{activeImage.source === ImageSource.DiagramsNet &&
!Desktop.isElectron() &&
!readOnly && (
<Tooltip content={t("Edit diagram")} placement="bottom">
<ActionButton
tabIndex={-1}
disabled={status.image === ImageStatus.ERROR}
onClick={handleEditDiagram}
aria-label={t("Edit diagram")}
size={32}
icon={<EditIcon />}
borderOnHover
neutral
/>
</Tooltip>
)}
{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
tabIndex={-1}
onClick={close}
aria-label={t("Close")}
size={32}
icon={<CloseIcon />}
borderOnHover
neutral
/>
</Tooltip>
</Dialog.Close>
</CloseAction>
{currentImageIndex > 0 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<Nav dir="left" $hidden={isIdle} animation={animation.current}>
<NavButton onClick={prev} size={32} aria-label={t("Previous")}>
<BackIcon size={32} />
</NavButton>
</Nav>
)}
<ZoomablePannablePinchable
panningDisabled={
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
)
}
disabled={status.image === ImageStatus.ERROR}
ref={zoomPanPinchRef}
onClose={close}
>
<Image
ref={imgRef}
src={activeImage.src}
alt={activeImage.alt}
onLoading={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADING,
})
}
onLoad={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.LOADED,
})
}
onError={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ERROR,
})
}
onSwipeRight={prev}
onSwipeLeft={next}
onSwipeUp={close}
onSwipeDown={close}
status={status}
animation={animation.current}
onMinZoom={() => {
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MIN_ZOOM,
});
}}
onZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.ZOOMED,
})
}
onMaxZoom={() =>
setStatus({
lightbox: status.lightbox,
image: ImageStatus.MAX_ZOOM,
})
}
/>
</ZoomablePannablePinchable>
{currentImageIndex < images.length - 1 &&
!(
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
) && (
<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>
);
}
type ImageProps = {
src: string;
alt: string;
onLoading: () => void;
onLoad: () => void;
onError: () => void;
onSwipeRight: () => void;
onSwipeLeft: () => void;
onSwipeUp: () => void;
onSwipeDown: () => void;
status: Status;
animation: Animation | null;
onMinZoom: () => void;
onZoom: () => void;
onMaxZoom: () => void;
};
const Image = forwardRef<HTMLImageElement, ImageProps>(function Image_(
{
src,
alt,
onLoading,
onLoad,
onError,
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
status,
animation,
onMinZoom,
onZoom,
onMaxZoom,
}: ImageProps,
ref
) {
const { t } = useTranslation();
const swipeHandlers = useSwipe({
onSwipeRight,
onSwipeLeft,
onSwipeUp,
onSwipeDown,
});
const { isImagePanning } = useContext(ZoomPanPinchContext);
useTransformEffect(({ state, instance }) => {
const minScale = instance.props.minScale ?? 1;
const maxScale = instance.props.maxScale ?? 8;
const { scale } = state;
if (scale === minScale && status.image === ImageStatus.ZOOMED) {
onMinZoom();
} else if (scale === maxScale && status.image === ImageStatus.ZOOMED) {
onMaxZoom();
} else if (
scale > minScale &&
scale < maxScale &&
status.image !== ImageStatus.ZOOMED
) {
onZoom();
}
});
const [hidden, setHidden] = useState(
status.image === null || status.image === ImageStatus.LOADING
);
useEffect(() => {
onLoading();
}, [src]);
useEffect(() => {
if (status.image === null || status.image === ImageStatus.LOADING) {
setHidden(true);
} else if (status.image === ImageStatus.LOADED) {
setHidden(false);
}
}, [status.image]);
return status.image === ImageStatus.ERROR ? (
<StyledError animation={animation} {...swipeHandlers}>
<CrossIcon size={16} /> {t("Image failed to load")}
</StyledError>
) : (
<>
{status.image === ImageStatus.LOADING && <LoadingIndicator />}
<Figure>
<StyledImg
ref={ref}
src={src}
alt={alt}
animation={animation}
onAnimationStart={() => setHidden(false)}
{...swipeHandlers}
onError={onError}
onLoad={onLoad}
$hidden={hidden}
$zoomedIn={
status.image === ImageStatus.ZOOMED ||
status.image === ImageStatus.MAX_ZOOM
}
$zoomedOut={status.image === ImageStatus.MIN_ZOOM}
$panning={isImagePanning}
/>
<Caption>
{status.image === ImageStatus.MIN_ZOOM &&
status.lightbox === LightboxStatus.OPENED ? (
<Fade>{alt}</Fade>
) : null}
</Caption>
</Figure>
</>
);
});
const Figure = styled("figure")`
width: 100%;
height: 100%;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
`;
const Caption = styled("figcaption")`
font-size: 14px;
min-height: 1.5em;
font-weight: normal;
margin-top: 8px;
color: ${s("textSecondary")};
flex-shrink: 0;
`;
const StyledOverlay = styled(Dialog.Overlay)<{
animation: Animation | null;
}>`
position: fixed;
inset: 0;
background-color: ${s("background")};
z-index: ${depths.overlay};
${(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 StyledImg = styled.img<{
$hidden: boolean;
$zoomedIn: boolean;
$zoomedOut: boolean;
$panning: boolean;
animation: Animation | null;
}>`
visibility: ${(props) => (props.$hidden ? "hidden" : "visible")};
pointer-events: auto !important;
max-width: 100%;
min-height: 0;
object-fit: contain;
cursor: ${(props) =>
props.$panning
? "grabbing"
: props.$zoomedOut
? "zoom-in"
: props.$zoomedIn
? "zoom-out"
: "default"};
${(props) =>
props.animation?.zoomIn
? css`
animation: ${props.animation.zoomIn.apply()}
${props.animation.zoomIn.duration}ms;
`
: props.animation?.zoomOut
? css`
animation: ${props.animation.zoomOut.apply()}
${props.animation.zoomOut.duration}ms;
`
: props.animation?.fadeOut
? css`
animation: ${props.animation.fadeOut.apply()}
${props.animation.fadeOut.duration}ms;
`
: ""}
`;
const StyledContent = styled(Dialog.Content)<{ $commentsOpen: boolean }>`
position: fixed;
inset: 0;
z-index: ${depths.modal};
display: flex;
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)`
background: transparent;
`;
const Actions = styled(HStack)<{
animation: Animation | null;
$commentsOpen: boolean;
}>`
position: absolute;
top: 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
? 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 Nav = styled.div<{
$hidden: boolean;
dir: "left" | "right";
animation: Animation | null;
$commentsOpen?: boolean;
}>`
position: absolute;
${(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) =>
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 StyledError = styled(ImageError)<{
animation: Animation | null;
}>`
${(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 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;
color: ${s("text")};
outline: none;
${extraArea(12)}
&:hover {
opacity: 1;
}
`;
export default observer(Lightbox);