feat(comment): hover tooltip shows author + all comments as plain lines (#268)
Per maintainer feedback: show the comment author and the whole thread (parent
+ replies), but as simple "Author: text" lines — no avatars, timestamps, or
thread chrome ("it's already clear they're comments on one entry, one after
another"). Also lengthen the open delay so the card doesn't pop up on a
passing glance.
- Render each comment in the thread as a plain line: bold "Name:" + text,
parent first then replies (createdAt asc). Empty-text comments are skipped.
- OPEN_DELAY_MS 120 -> 350.
- Drop the avatar/relative-time/divider UI (and the CustomAvatar/timeAgo
imports). buildThread (root + direct replies) is unchanged — the comment
model is flat, so direct children of the root are the full thread.
Tests updated to the "Author: text" shape (textContent-based, incl. ordering).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(<Harness />);
|
||||
|
||||
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(<Harness />);
|
||||
|
||||
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(<Harness />);
|
||||
|
||||
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(<Harness />);
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<string, IComment>,
|
||||
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<string, IComment>();
|
||||
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) => (
|
||||
<Text
|
||||
key={row.id}
|
||||
size="xs"
|
||||
mt={4}
|
||||
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
|
||||
>
|
||||
{/* "Author: text" — one line per comment, parent then replies. */}
|
||||
<Text span fw={600}>
|
||||
{row.name}:
|
||||
</Text>{" "}
|
||||
{row.text}
|
||||
</Text>
|
||||
))}
|
||||
</Paper>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user