carbon-tokenization / frontend /src /components /CommentPopover.tsx
tfrere's picture
tfrere HF Staff
feat(frontend): editor refresh (embed studio, comment popover, shiki, top bar, hooks, styles)
76fc93a
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>
);
}