Spaces:
Runtime error
Runtime error
| 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; // 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<string | null>(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 ( | |
| <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> | |
| ); | |
| } | |