import { useState, useEffect, useCallback, useRef } from "react"; import { CheckCircle, Trash2, Send, MessageCircle } from "lucide-react"; import type { Editor } from "@tiptap/core"; import type { CommentStore, CommentData } from "../editor/comments"; interface Props { editor: Editor | null; commentStore: CommentStore | null; user: { name: string; color: string; avatarUrl?: string }; } interface CommentPosition { id: string; top: number; } function buildMarkPositions(editor: Editor): Map { const map = new Map(); editor.state.doc.descendants((node, pos) => { for (const mark of node.marks) { if (mark.type.name === "comment" && !map.has(mark.attrs.commentId)) { map.set(mark.attrs.commentId, pos); } } }); return map; } export function CommentMarginIcons({ editor, commentStore, user }: Props) { const [positions, setPositions] = useState([]); const [activeId, setActiveId] = useState(null); const [activeComment, setActiveComment] = useState(null); const [replyText, setReplyText] = useState(""); const popoverRef = useRef(null); const replyInputRef = useRef(null); const activeIdRef = useRef(null); activeIdRef.current = activeId; const updatePositions = useCallback(() => { if (!editor || !commentStore) return; if (activeIdRef.current) return; // freeze while popover open const comments = commentStore.getAll().filter((c) => !c.resolved); if (comments.length === 0) { setPositions([]); return; } const scrollParent = editor.view.dom.closest(".editor-scroll"); if (!scrollParent) return; const scrollTop = scrollParent.scrollTop; const scrollRect = scrollParent.getBoundingClientRect(); const markPos = buildMarkPositions(editor); const result: CommentPosition[] = []; for (const c of comments) { const pos = markPos.get(c.id); if (pos === undefined) continue; try { const coords = editor.view.coordsAtPos(pos); const top = coords.top - scrollRect.top + scrollTop; result.push({ id: c.id, top }); } catch { /* not mapped */ } } result.sort((a, b) => a.top - b.top); const MIN_GAP = 32; for (let i = 1; i < result.length; i++) { if (result[i].top < result[i - 1].top + MIN_GAP) { result[i].top = result[i - 1].top + MIN_GAP; } } setPositions(result); }, [editor, commentStore]); useEffect(() => { updatePositions(); if (!commentStore) return; return commentStore.observe(updatePositions); }, [commentStore, updatePositions]); useEffect(() => { if (!editor) return; const scrollParent = editor.view.dom.closest(".editor-scroll"); if (!scrollParent) return; let timer = 0; const debounced = () => { clearTimeout(timer); timer = window.setTimeout(updatePositions, 120); }; scrollParent.addEventListener("scroll", debounced, { passive: true }); editor.on("update", debounced); return () => { scrollParent.removeEventListener("scroll", debounced); editor.off("update", debounced); clearTimeout(timer); }; }, [editor, updatePositions]); // Also open popover when clicking on a comment-mark in the text useEffect(() => { if (!editor || !commentStore) return; const handleSelection = () => { const { from, to } = editor.state.selection; if (from !== to) return; const resolved = editor.state.doc.resolve(from); for (const mark of resolved.marks()) { if (mark.type.name === "comment" && mark.attrs.commentId) { const id = mark.attrs.commentId as string; const comment = commentStore.get(id); if (comment && !comment.resolved) { setActiveId(id); setActiveComment(comment); setReplyText(""); return; } } } }; editor.on("selectionUpdate", handleSelection); return () => { editor.off("selectionUpdate", handleSelection); }; }, [editor, commentStore]); // Refresh active comment data from store useEffect(() => { if (!activeId || !commentStore) return; const refresh = () => { const updated = commentStore.get(activeId); if (updated) setActiveComment(updated); else { setActiveComment(null); setActiveId(null); } }; return commentStore.observe(refresh); }, [activeId, commentStore]); // Recalculate positions when popover closes (unfreeze) const prevActiveId = useRef(null); useEffect(() => { if (prevActiveId.current && !activeId) { setTimeout(updatePositions, 50); } prevActiveId.current = activeId; }, [activeId, updatePositions]); // Close on outside click useEffect(() => { if (!activeId) return; const handler = (e: MouseEvent) => { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { const target = e.target as HTMLElement; if (target.closest(".comment-margin-icon")) return; setActiveId(null); setActiveComment(null); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [activeId]); const handleIconClick = (id: string) => { if (!commentStore) return; if (activeId === id) { setActiveId(null); setActiveComment(null); return; } const comment = commentStore.get(id); if (!comment) return; setActiveId(id); setActiveComment(comment); setReplyText(""); }; const handleReply = () => { if (!replyText.trim() || !commentStore || !activeId) return; commentStore.addReply(activeId, { id: `r_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, author: user.name, authorColor: user.color, text: replyText.trim(), createdAt: Date.now(), }); setReplyText(""); setTimeout(() => replyInputRef.current?.focus(), 50); }; const removeMarkAndData = useCallback((id: string) => { if (!commentStore || !editor) return; commentStore.remove(id); const { doc, tr } = editor.state; const markType = editor.schema.marks.comment; doc.descendants((node, pos) => { node.marks.forEach((mark) => { if (mark.type === markType && mark.attrs.commentId === id) { tr.removeMark(pos, pos + node.nodeSize, markType); } }); }); editor.view.dispatch(tr); setActiveId(null); setActiveComment(null); }, [commentStore, editor]); const activePos = positions.find((p) => p.id === activeId); const formatTime = (ts: number) => new Date(ts).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); return (
{positions.map((p) => (
handleIconClick(p.id)} >
))} {activeComment && activePos && (
{activeComment.author} {formatTime(activeComment.createdAt)}
{activeComment.text}
{activeComment.replies.map((reply) => (
{reply.author} {formatTime(reply.createdAt)}
{reply.text}
))}