mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
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:
+173
-11
@@ -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);
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user