| 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<string, number> { |
| const map = new Map<string, number>(); |
| 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<CommentPosition[]>([]); |
| const [activeId, setActiveId] = useState<string | null>(null); |
| const [activeComment, setActiveComment] = useState<CommentData | null>(null); |
| const [replyText, setReplyText] = useState(""); |
| const popoverRef = useRef<HTMLDivElement>(null); |
| const replyInputRef = useRef<HTMLTextAreaElement>(null); |
|
|
| const activeIdRef = useRef<string | null>(null); |
| activeIdRef.current = activeId; |
|
|
| const updatePositions = useCallback(() => { |
| if (!editor || !commentStore) return; |
| if (activeIdRef.current) return; |
| 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 { } |
| } |
|
|
| 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]); |
|
|
| |
| 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]); |
|
|
| |
| 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]); |
|
|
| |
| const prevActiveId = useRef<string | null>(null); |
| useEffect(() => { |
| if (prevActiveId.current && !activeId) { |
| setTimeout(updatePositions, 50); |
| } |
| prevActiveId.current = activeId; |
| }, [activeId, updatePositions]); |
|
|
| |
| 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 ( |
| <div className="comment-margin-strip"> |
| {positions.map((p) => ( |
| <div |
| key={p.id} |
| className={`comment-margin-icon ${activeId === p.id ? "comment-margin-icon--active" : ""}`} |
| style={{ top: p.top }} |
| onClick={() => handleIconClick(p.id)} |
| > |
| <MessageCircle size={14} /> |
| </div> |
| ))} |
| |
| {activeComment && activePos && ( |
| <div |
| ref={popoverRef} |
| className="comment-popover" |
| style={{ top: activePos.top }} |
| > |
| <div className="comment-popover__thread"> |
| <div className="comment-popover__message"> |
| <div className="comment-popover__header"> |
| <span className="comment-popover__author" style={{ color: activeComment.authorColor }}> |
| {activeComment.author} |
| </span> |
| <span className="comment-popover__time">{formatTime(activeComment.createdAt)}</span> |
| </div> |
| <div className="comment-popover__text">{activeComment.text}</div> |
| </div> |
| |
| {activeComment.replies.map((reply) => ( |
| <div key={reply.id} className="comment-popover__message comment-popover__reply"> |
| <div className="comment-popover__header"> |
| <span className="comment-popover__author" style={{ color: reply.authorColor }}> |
| {reply.author} |
| </span> |
| <span className="comment-popover__time">{formatTime(reply.createdAt)}</span> |
| </div> |
| <div className="comment-popover__text">{reply.text}</div> |
| </div> |
| ))} |
| </div> |
| |
| <div className="comment-popover__input-row"> |
| <textarea |
| ref={replyInputRef} |
| className="comment-popover__input" |
| rows={1} |
| placeholder="Reply..." |
| value={replyText} |
| onChange={(e) => setReplyText(e.target.value)} |
| onKeyDown={(e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| handleReply(); |
| } |
| }} |
| /> |
| <button |
| className="comment-popover__send" |
| onClick={handleReply} |
| disabled={!replyText.trim()} |
| aria-label="Send reply" |
| > |
| <Send size={14} /> |
| </button> |
| </div> |
| |
| <div className="comment-popover__actions"> |
| <button className="comment-popover__resolve" onClick={() => removeMarkAndData(activeComment.id)}> |
| <CheckCircle size={14} /> |
| Resolve |
| </button> |
| <button className="comment-popover__delete" onClick={() => removeMarkAndData(activeComment.id)}> |
| <Trash2 size={14} /> |
| </button> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|