diff --git a/apps/client/src/features/comment/components/comment-hover-preview.test.tsx b/apps/client/src/features/comment/components/comment-hover-preview.test.tsx index f35dbcdd..6f7289e8 100644 --- a/apps/client/src/features/comment/components/comment-hover-preview.test.tsx +++ b/apps/client/src/features/comment/components/comment-hover-preview.test.tsx @@ -169,8 +169,13 @@ describe("CommentHoverPreview — hover behaviour", () => { vi.useRealTimers(); }); - it("shows the comment text after the open delay", () => { - setComments([comment()]); + it("shows the parent comment text and author after the open delay", () => { + setComments([ + comment({ + content: doc("Hello world"), + creator: { id: "u-1", name: "Alice", avatarUrl: null } as any, + }), + ]); render(); hoverMark(); @@ -178,27 +183,102 @@ describe("CommentHoverPreview — hover behaviour", () => { expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); act(() => { - vi.advanceTimersByTime(120); + vi.advanceTimersByTime(350); }); const card = screen.getByTestId("comment-hover-preview"); - expect(card.textContent).toBe("Hello world"); + // The line shows "Author: text" — both the author name and the comment text. + expect(card.textContent).toContain("Alice:"); + expect(card.textContent).toContain("Hello world"); // The card MUST NOT intercept the mark's click (which opens the side panel): // pointer-events:none is the single property guaranteeing that — lock it so // a regression dropping it from the style object fails here. expect(card.style.pointerEvents).toBe("none"); }); + it("renders the whole thread: parent plus replies, each with its author", () => { + setComments([ + comment({ + id: "c-1", + content: doc("Parent comment"), + createdAt: new Date("2026-01-01T10:00:00Z"), + creator: { id: "u-1", name: "Alice", avatarUrl: null } as any, + }), + comment({ + id: "c-3", + content: doc("Second reply"), + parentCommentId: "c-1", + createdAt: new Date("2026-01-01T12:00:00Z"), + creator: { id: "u-3", name: "Carol", avatarUrl: null } as any, + }), + comment({ + id: "c-2", + content: doc("First reply"), + parentCommentId: "c-1", + createdAt: new Date("2026-01-01T11:00:00Z"), + creator: { id: "u-2", name: "Bob", avatarUrl: null } as any, + }), + ]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(350); + }); + const card = screen.getByTestId("comment-hover-preview"); + + // Parent and both replies are present, each as "Author: text". + const body = card.textContent ?? ""; + expect(body).toContain("Alice: Parent comment"); + expect(body).toContain("Bob: First reply"); + expect(body).toContain("Carol: Second reply"); + + // Replies are ordered by createdAt ascending after the parent + // (Parent -> First reply -> Second reply), even though the input was + // out of order (Second reply's comment came before First reply's). + expect(body.indexOf("Parent comment")).toBeLessThan( + body.indexOf("First reply"), + ); + expect(body.indexOf("First reply")).toBeLessThan( + body.indexOf("Second reply"), + ); + }); + + it("shows the thread even when the parent text is empty but it has replies", () => { + setComments([ + comment({ + id: "c-1", + content: JSON.stringify({ type: "doc", content: [] }), + creator: { id: "u-1", name: "Alice", avatarUrl: null } as any, + }), + comment({ + id: "c-2", + content: doc("A reply"), + parentCommentId: "c-1", + createdAt: new Date(), + creator: { id: "u-2", name: "Bob", avatarUrl: null } as any, + }), + ]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(350); + }); + const card = screen.getByTestId("comment-hover-preview"); + expect(card.textContent).toContain("Bob: A reply"); + }); + it("hides on mouseout", () => { setComments([comment()]); render(); hoverMark(); act(() => { - vi.advanceTimersByTime(120); + vi.advanceTimersByTime(350); }); - expect(screen.getByTestId("comment-hover-preview").textContent).toBe( - "Hello world", - ); + expect( + screen.getByTestId("comment-hover-preview").textContent, + ).toContain("Hello world"); leaveMark(); expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); @@ -258,11 +338,11 @@ describe("CommentHoverPreview — hover behaviour", () => { hoverMark(); act(() => { - vi.advanceTimersByTime(120); + vi.advanceTimersByTime(350); }); - expect(screen.getByTestId("comment-hover-preview").textContent).toBe( - "Hello world", - ); + expect( + screen.getByTestId("comment-hover-preview").textContent, + ).toContain("Hello world"); act(() => { window.dispatchEvent(new Event("scroll")); @@ -276,11 +356,11 @@ describe("CommentHoverPreview — hover behaviour", () => { hoverMark(); act(() => { - vi.advanceTimersByTime(120); + vi.advanceTimersByTime(350); }); - expect(screen.getByTestId("comment-hover-preview").textContent).toBe( - "Hello world", - ); + expect( + screen.getByTestId("comment-hover-preview").textContent, + ).toContain("Hello world"); const span = screen.getByTestId("mark"); act(() => { @@ -295,7 +375,7 @@ describe("CommentHoverPreview — hover behaviour", () => { hoverMark(); act(() => { - vi.advanceTimersByTime(120); + vi.advanceTimersByTime(350); }); expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull(); @@ -315,7 +395,7 @@ describe("CommentHoverPreview — hover behaviour", () => { hoverMark(); act(() => { - vi.advanceTimersByTime(120); + vi.advanceTimersByTime(350); }); expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull(); diff --git a/apps/client/src/features/comment/components/comment-hover-preview.tsx b/apps/client/src/features/comment/components/comment-hover-preview.tsx index ec6861df..5f68f5eb 100644 --- a/apps/client/src/features/comment/components/comment-hover-preview.tsx +++ b/apps/client/src/features/comment/components/comment-hover-preview.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { Paper } from "@mantine/core"; +import { Paper, Text } from "@mantine/core"; import { useCommentsQuery } from "@/features/comment/queries/comment-query"; import { IComment } from "@/features/comment/types/comment.types"; import { commentContentToText } from "@/features/comment/utils/comment-content-to-text"; @@ -11,15 +11,25 @@ interface CommentHoverPreviewProps { } // Delay before the card appears, to avoid flicker when the pointer quickly -// passes over comment marks. -const OPEN_DELAY_MS = 120; -const CARD_MAX_WIDTH = 320; +// passes over comment marks (kept generous so it does not pop up on a passing +// glance). +const OPEN_DELAY_MS = 350; +const CARD_MAX_WIDTH = 360; +const CARD_MAX_HEIGHT = 300; const GAP = 6; // Reserve roughly this much room below the span; flip above when it doesn't fit. -const ESTIMATED_CARD_HEIGHT = 160; +const ESTIMATED_CARD_HEIGHT = 200; + +// One rendered line of the thread: the author and the comment's plain text, +// pre-computed at hover time so render stays cheap. Shown as "Author: text". +interface ThreadRow { + id: string; + name: string; + text: string; +} interface HoverState { - text: string; + thread: ThreadRow[]; rect: { top: number; bottom: number; left: number }; } @@ -27,9 +37,32 @@ function isResolved(comment: IComment): boolean { return comment.resolvedAt != null || comment.resolvedById != null; } +// Build the thread for a root (parent) comment: the root first, followed by its +// replies sorted by createdAt ascending. Reads every comment from the map. +function buildThread( + commentMap: Map, + root: IComment, +): ThreadRow[] { + const replies: IComment[] = []; + commentMap.forEach((comment) => { + if (comment.parentCommentId === root.id) replies.push(comment); + }); + replies.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + + return [root, ...replies].map((comment) => ({ + id: comment.id, + name: comment.creator?.name ?? "", + text: commentContentToText(comment.content), + })); +} + /** - * Shows a small floating card with the plain text of the parent comment when - * the user hovers a `.comment-mark` span in the main editor. Read-only: + * Shows a small floating card when the user hovers a `.comment-mark` span in the + * main editor: the parent comment plus all its replies, one per line as + * "Author: text" (plain — no avatars or timestamps). Read-only: * `pointer-events: none` so it never intercepts the mark's click (which opens * the side panel via ACTIVE_COMMENT_EVENT). Resolved/unknown marks show nothing. */ @@ -39,8 +72,8 @@ export default function CommentHoverPreview({ }: CommentHoverPreviewProps) { const { data } = useCommentsQuery({ pageId }); - // Map of commentId -> comment. Only parent comments anchor marks, but indexing - // every comment by id is harmless and keeps the lookup a single Map access. + // Map of commentId -> comment. The map indexes every comment (parents and + // replies) so a thread can be assembled from a single source. const commentMap = useMemo(() => { const map = new Map(); data?.items?.forEach((comment) => map.set(comment.id, comment)); @@ -102,12 +135,16 @@ export default function CommentHoverPreview({ return; } - // Already tracking this span: nothing to do (avoids re-parsing the - // comment content on every intra-span mousemove). + // Already tracking this span: nothing to do (avoids re-building the thread + // on every intra-span mousemove). if (span === activeSpanRef.current) return; - const text = commentContentToText(comment.content); - if (!text) return; + const thread = buildThread(commentMapRef.current, comment); + // Show the card when the root has text OR it has at least one reply. + // A thread of a single empty-text root carries nothing worth showing. + const hasContent = + thread.length > 1 || thread.some((row) => row.text.length > 0); + if (!hasContent) return; activeSpanRef.current = span; @@ -117,7 +154,7 @@ export default function CommentHoverPreview({ if (activeSpanRef.current !== span || !span.isConnected) return; const rect = span.getBoundingClientRect(); setHover({ - text, + thread, rect: { top: rect.top, bottom: rect.bottom, left: rect.left }, }); }, OPEN_DELAY_MS); @@ -191,21 +228,36 @@ export default function CommentHoverPreview({ ...positionStyle, zIndex: 1000, maxWidth: CARD_MAX_WIDTH, - maxHeight: ESTIMATED_CARD_HEIGHT, + // The card is pointer-events:none, so it can't scroll; clamp long + // threads instead (most threads are short). + maxHeight: CARD_MAX_HEIGHT, overflow: "hidden", - padding: "6px 10px", + padding: "8px 10px", fontSize: "13px", lineHeight: 1.4, // Never intercept clicks targeting the comment-mark span beneath. pointerEvents: "none", - whiteSpace: "pre-wrap", wordBreak: "break-word", - display: "-webkit-box", - WebkitLineClamp: 6, - WebkitBoxOrient: "vertical", }} > - {hover.text} + {hover.thread + // A comment with no plain text (e.g. an image-only reply) adds nothing + // to a text preview — skip its line. + .filter((row) => row.text.length > 0) + .map((row) => ( + + {/* "Author: text" — one line per comment, parent then replies. */} + + {row.name}: + {" "} + {row.text} + + ))} , document.body, );