From a848003db22ac21b72c0a6197e3ec6b684a63dc9 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Wed, 1 Jul 2026 00:58:13 +0300 Subject: [PATCH] feat(comment): hover tooltip with the comment text over comment marks (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CommentHoverPreview, mounted in page-editor next to : hovering a `.comment-mark[data-comment-id]` span shows a small floating card (createPortal, position:fixed, pointer-events:none so it never intercepts the mark's click) with the parent comment's plain text. Uses useCommentsQuery (shares the ["comments", pageId] cache with the side panel — no extra request). Skips unknown/not-yet-loaded, resolved (data-resolved attr or resolvedAt/resolvedById), and empty-text comments. A ~120ms open delay avoids flicker; hides on mouseout / mousedown / scroll(capture) / resize / page change. commentContentToText flattens the comment's ProseMirror doc (stringified or parsed) to plain text, preserving hardBreaks as newlines and never throwing. Main editor only (read-only / shares / history out of scope). closes #268 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/comment-hover-preview.test.tsx | 323 ++++++++++++++++++ .../components/comment-hover-preview.tsx | 212 ++++++++++++ .../comment/utils/comment-content-to-text.ts | 71 ++++ .../src/features/editor/page-editor.tsx | 6 + 4 files changed, 612 insertions(+) create mode 100644 apps/client/src/features/comment/components/comment-hover-preview.test.tsx create mode 100644 apps/client/src/features/comment/components/comment-hover-preview.tsx create mode 100644 apps/client/src/features/comment/utils/comment-content-to-text.ts 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 new file mode 100644 index 00000000..99c942fa --- /dev/null +++ b/apps/client/src/features/comment/components/comment-hover-preview.test.tsx @@ -0,0 +1,323 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { useRef } from "react"; +import { MantineProvider } from "@mantine/core"; +import { IComment } from "@/features/comment/types/comment.types"; + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +// Stub the comments query so the component renders without react-query/network. +const mockUseCommentsQuery = vi.fn(); +vi.mock("@/features/comment/queries/comment-query", () => ({ + useCommentsQuery: (params: { pageId: string }) => + mockUseCommentsQuery(params), +})); + +import CommentHoverPreview from "./comment-hover-preview"; +import { commentContentToText } from "@/features/comment/utils/comment-content-to-text"; + +const doc = (text: string) => + JSON.stringify({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }); + +const comment = (over?: Partial): IComment => + ({ + id: "c-1", + content: doc("Hello world"), + creatorId: "u-1", + pageId: "page-1", + workspaceId: "ws-1", + createdAt: new Date(), + creator: { id: "u-1", name: "User", avatarUrl: null } as any, + ...over, + }) as IComment; + +function setComments(items: IComment[]) { + mockUseCommentsQuery.mockReturnValue({ + data: { items, meta: {} }, + isLoading: false, + isError: false, + }); +} + +// Test harness: owns the container ref, hosts a comment-mark span and the +// preview component, mirroring how page-editor mounts it next to EditorContent. +function Harness({ + spanAttrs = { "data-comment-id": "c-1" }, + pageId = "page-1", +}: { + spanAttrs?: Record; + pageId?: string; +}) { + const containerRef = useRef(null); + return ( + +
+ + marked text + + +
+
+ ); +} + +function hoverMark() { + const span = screen.getByTestId("mark"); + act(() => { + span.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); +} + +function leaveMark() { + const span = screen.getByTestId("mark"); + act(() => { + span.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); + }); +} + +describe("commentContentToText", () => { + it("flattens a multi-node ProseMirror doc to plain text", () => { + const content = JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Hello " }, + { type: "text", text: "world" }, + ], + }, + { type: "paragraph", content: [{ type: "text", text: "Second line" }] }, + ], + }); + expect(commentContentToText(content)).toBe("Hello world\nSecond line"); + }); + + it("joins nested block structures (lists) on block boundaries", () => { + const content = { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "one" }] }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "two" }] }, + ], + }, + ], + }, + ], + }; + expect(commentContentToText(content)).toBe("one\ntwo"); + }); + + it("accepts an already-parsed object", () => { + expect(commentContentToText({ type: "doc", content: [] })).toBe(""); + }); + + it("returns '' for empty / missing / malformed content", () => { + expect(commentContentToText("")).toBe(""); + expect(commentContentToText(" ")).toBe(""); + expect(commentContentToText(undefined)).toBe(""); + expect(commentContentToText(null)).toBe(""); + expect(commentContentToText(JSON.stringify({ type: "doc", content: [] }))).toBe( + "", + ); + }); + + it("falls back to the raw string when content is not JSON", () => { + expect(commentContentToText("plain text")).toBe("plain text"); + }); + + it("preserves a hardBreak inside a paragraph as a newline", () => { + const content = JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "line1" }, + { type: "hardBreak" }, + { type: "text", text: "line2" }, + ], + }, + ], + }); + expect(commentContentToText(content)).toBe("line1\nline2"); + }); +}); + +describe("CommentHoverPreview — hover behaviour", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockUseCommentsQuery.mockReset(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("shows the comment text after the open delay", () => { + setComments([comment()]); + render(); + + hoverMark(); + // Before the delay elapses there is no card. + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + + act(() => { + vi.advanceTimersByTime(120); + }); + const card = screen.getByTestId("comment-hover-preview"); + expect(card.textContent).toBe("Hello world"); + }); + + it("hides on mouseout", () => { + setComments([comment()]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(120); + }); + expect(screen.getByTestId("comment-hover-preview").textContent).toBe( + "Hello world", + ); + + leaveMark(); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + + it("does not show a card for a resolved comment (data-resolved)", () => { + setComments([comment()]); + render( + , + ); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + + it("does not show a card for a resolved comment (resolvedAt set)", () => { + setComments([comment({ resolvedAt: new Date() })]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + + it("does not show a card for an unknown comment id", () => { + setComments([comment()]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + + it("does not show a card when the comment text is empty", () => { + setComments([comment({ content: JSON.stringify({ type: "doc", content: [] }) })]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(200); + }); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + + it("hides on scroll", () => { + setComments([comment()]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(120); + }); + expect(screen.getByTestId("comment-hover-preview").textContent).toBe( + "Hello world", + ); + + act(() => { + window.dispatchEvent(new Event("scroll")); + }); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + + it("hides on mousedown (clicking the mark to open the panel dismisses the card)", () => { + setComments([comment()]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(120); + }); + expect(screen.getByTestId("comment-hover-preview").textContent).toBe( + "Hello world", + ); + + const span = screen.getByTestId("mark"); + act(() => { + span.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + }); + expect(screen.queryByTestId("comment-hover-preview")).toBeNull(); + }); + + it("does not hide when the pointer moves WITHIN the same span (anti-flicker)", () => { + setComments([comment()]); + render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(120); + }); + expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull(); + + // mouseout whose relatedTarget is still inside the span must NOT hide. + const span = screen.getByTestId("mark"); + act(() => { + span.dispatchEvent( + new MouseEvent("mouseout", { bubbles: true, relatedTarget: span }), + ); + }); + expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull(); + }); + + it("hides when the page changes", () => { + setComments([comment()]); + const { rerender } = render(); + + hoverMark(); + act(() => { + vi.advanceTimersByTime(120); + }); + expect(screen.queryByTestId("comment-hover-preview")).not.toBeNull(); + + act(() => { + rerender(); + }); + expect(screen.queryByTestId("comment-hover-preview")).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 new file mode 100644 index 00000000..ec6861df --- /dev/null +++ b/apps/client/src/features/comment/components/comment-hover-preview.tsx @@ -0,0 +1,212 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Paper } 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"; + +interface CommentHoverPreviewProps { + pageId: string; + containerRef: React.RefObject; +} + +// 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; +const GAP = 6; +// Reserve roughly this much room below the span; flip above when it doesn't fit. +const ESTIMATED_CARD_HEIGHT = 160; + +interface HoverState { + text: string; + rect: { top: number; bottom: number; left: number }; +} + +function isResolved(comment: IComment): boolean { + return comment.resolvedAt != null || comment.resolvedById != null; +} + +/** + * 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: + * `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. + */ +export default function CommentHoverPreview({ + pageId, + containerRef, +}: 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. + const commentMap = useMemo(() => { + const map = new Map(); + data?.items?.forEach((comment) => map.set(comment.id, comment)); + return map; + }, [data]); + + // Read the latest map from the delegated listeners without re-attaching them + // every time the comments query refreshes. + const commentMapRef = useRef(commentMap); + useEffect(() => { + commentMapRef.current = commentMap; + }, [commentMap]); + + const [hover, setHover] = useState(null); + const openTimerRef = useRef | null>(null); + const activeSpanRef = useRef(null); + + const clearOpenTimer = () => { + if (openTimerRef.current !== null) { + clearTimeout(openTimerRef.current); + openTimerRef.current = null; + } + }; + + const hide = () => { + clearOpenTimer(); + activeSpanRef.current = null; + setHover(null); + }; + + // Hide and reset when the page changes (the comment set belongs to a page): + // the cleanup runs on every pageId change before the effect re-runs. + useEffect(() => { + return () => hide(); + }, [pageId]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleMouseOver = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + const span = target?.closest( + ".comment-mark[data-comment-id]", + ); + if (!span) return; + + const commentId = span.getAttribute("data-comment-id"); + if (!commentId) return; + + const comment = commentMapRef.current.get(commentId); + // Unknown (not loaded yet) or resolved -> no tooltip. Resolved marks also + // carry data-resolved="true"; check both the data attribute and the model. + if ( + !comment || + span.hasAttribute("data-resolved") || + isResolved(comment) + ) { + return; + } + + // Already tracking this span: nothing to do (avoids re-parsing the + // comment content on every intra-span mousemove). + if (span === activeSpanRef.current) return; + + const text = commentContentToText(comment.content); + if (!text) return; + + activeSpanRef.current = span; + + clearOpenTimer(); + openTimerRef.current = setTimeout(() => { + openTimerRef.current = null; + if (activeSpanRef.current !== span || !span.isConnected) return; + const rect = span.getBoundingClientRect(); + setHover({ + text, + rect: { top: rect.top, bottom: rect.bottom, left: rect.left }, + }); + }, OPEN_DELAY_MS); + }; + + const handleMouseOut = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + const span = target?.closest( + ".comment-mark[data-comment-id]", + ); + if (!span) return; + + // Ignore moves that stay within the same comment-mark span. + const related = event.relatedTarget as HTMLElement | null; + if (related && span.contains(related)) return; + + if (span === activeSpanRef.current) hide(); + }; + + // Scroll uses capture so it also catches scrolling inside nested containers. + const handleScroll = () => hide(); + const handleResize = () => hide(); + // Dismiss on press: clicking a mark opens the side panel, and the card + // would otherwise linger (no mouseout fires while the pointer stays put). + const handleMouseDown = () => hide(); + + container.addEventListener("mouseover", handleMouseOver); + container.addEventListener("mouseout", handleMouseOut); + container.addEventListener("mousedown", handleMouseDown); + window.addEventListener("scroll", handleScroll, true); + window.addEventListener("resize", handleResize); + + return () => { + container.removeEventListener("mouseover", handleMouseOver); + container.removeEventListener("mouseout", handleMouseOut); + container.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("scroll", handleScroll, true); + window.removeEventListener("resize", handleResize); + clearOpenTimer(); + }; + }, [containerRef]); + + if (!hover) return null; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + // Flip above when there isn't enough room below the span. + const placeAbove = + hover.rect.bottom + ESTIMATED_CARD_HEIGHT > viewportHeight && + hover.rect.top > ESTIMATED_CARD_HEIGHT; + + const left = Math.max( + 8, + Math.min(hover.rect.left, viewportWidth - CARD_MAX_WIDTH - 8), + ); + + const positionStyle: React.CSSProperties = placeAbove + ? { bottom: viewportHeight - hover.rect.top + GAP } + : { top: hover.rect.bottom + GAP }; + + return createPortal( + + {hover.text} + , + document.body, + ); +} diff --git a/apps/client/src/features/comment/utils/comment-content-to-text.ts b/apps/client/src/features/comment/utils/comment-content-to-text.ts new file mode 100644 index 00000000..97c682ba --- /dev/null +++ b/apps/client/src/features/comment/utils/comment-content-to-text.ts @@ -0,0 +1,71 @@ +/** + * Flatten a comment's ProseMirror JSON document to plain text. + * + * `IComment.content` is stored as a stringified ProseMirror doc, but this also + * accepts an already-parsed object. Walks the node tree, concatenating `text` + * leaves and joining text-bearing blocks with newlines. Missing, empty or + * malformed content yields an empty string (never throws). + */ +export function commentContentToText(content: unknown): string { + let doc: any = content; + + if (typeof content === "string") { + const trimmed = content.trim(); + if (!trimmed) return ""; + try { + doc = JSON.parse(trimmed); + } catch { + // Not JSON — fall back to treating the raw string as plain text. + return trimmed; + } + } + + if (!doc || typeof doc !== "object") return ""; + + const blocks: string[] = []; + + const walk = (node: any): void => { + if (!node || typeof node !== "object") return; + + if (typeof node.text === "string") { + // Inline text leaf: append to the current block line. + if (blocks.length === 0) blocks.push(""); + blocks[blocks.length - 1] += node.text; + return; + } + + if (node.type === "hardBreak") { + // A soft line break inside a block: keep the newline so the two halves + // do not run together. + if (blocks.length === 0) blocks.push(""); + blocks[blocks.length - 1] += "\n"; + return; + } + + const children = Array.isArray(node.content) ? node.content : []; + const containsText = children.some( + (child: any) => + child && typeof child === "object" && typeof child.text === "string", + ); + + if (containsText) { + // Text-bearing block (paragraph, heading, ...): start a fresh line, then + // collect its inline text. + blocks.push(""); + children.forEach(walk); + return; + } + + // Structural container (doc, list, blockquote, ...): recurse so each nested + // text block becomes its own line. + children.forEach(walk); + }; + + walk(doc); + + return blocks + .map((block) => block.trim()) + .filter((block) => block.length > 0) + .join("\n") + .trim(); +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index 453444d8..10d65a7e 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -42,6 +42,7 @@ import { showReadOnlyCommentPopupAtom, } from "@/features/comment/atoms/comment-atom"; import CommentDialog from "@/features/comment/components/comment-dialog"; +import CommentHoverPreview from "@/features/comment/components/comment-hover-preview"; import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu"; import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu"; import TableMenu from "@/features/editor/components/table/table-menu.tsx"; @@ -533,6 +534,11 @@ export default function PageEditor({
+ + {editor && ( )}