shadimage
elements
blockpage-commentssource: ok · hit

Page Comments

Figma-style commenting overlay with text selection highlights, pin comments, keyboard navigation, and replies. Includes adapters for Vercel KV, Upstash, Supabase, and Redis.

npx shadcn add "https://www.tryelements.dev/r/page-comments.json"
free

Where it goes

kanbancomment

Interaction

dropdown

Use cases

community

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files6 · source cached 10/14/2026, 1:57:06 PM

registry/default/blocks/collaboration/page-comments/components/elements/page-comments.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

import { createPortal } from "react-dom";

import {
  inMemoryAdapter,
  type NewComment,
  type PageCommentsAdapter,
  type PageCommentsComment,
} from "./page-comments-adapters";

interface SelectionPopup {
  x: number;
  y: number;
  text: string;
}

export interface PageCommentsUser {
  name: string;
  avatar?: string;
  color?: string;
}

export interface PageCommentsProps {
  pageId: string;
  adapter?: PageCommentsAdapter;
  contentSelector?: string;
  user: PageCommentsUser;
  keyboard?: boolean;
  highlightStyle?: "notion" | "minimal" | "none";
  onComment?: (comment: PageCommentsComment) => void;
  onResolve?: (commentId: string) => void;
  pollInterval?: number;
}

const COLORS = [
  "#E93D82",
  "#3E63DD",
  "#30A46C",
  "#E5484D",
  "#6E56CF",
  "#F76B15",
  "#12A594",
  "#7C66DC",
];

function getColor(name: string, override?: string) {
  if (override) return override;
  let hash = 0;
  for (let i = 0; i < name.length; i++) {
    hash = name.charCodeAt(i) + ((hash << 5) - hash);
  }
  return COLORS[Math.abs(hash) % COLORS.length];
}

function timeAgo(ts: number) {
  const diff = Date.now() - ts;
  const mins = Math.floor(diff / 60000);
  if (mins < 1) return "just now";
  if (mins < 60) return `${mins}m ago`;
  const hrs = Math.floor(mins / 60);
  if (hrs < 24) return `${hrs}h ago`;
  return `${Math.floor(hrs / 24)}d ago`;
}

function makeCursor(fillColor: string) {
  const encoded = encodeURIComponent(fillColor);
  return `url("data:image/svg+xml,%3Csvg width='22' height='26' viewBox='0 0 396 434' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M49.7 29.8L346.2 199.7L202.3 244l-82.9 119.1L49.7 29.8Z' fill='${encoded}' stroke='white' stroke-width='20'/%3E%3C/svg%3E") 3 1, crosshair`;
}

function highlightQuotes(
  quotes: { text: string; active: boolean; commentId?: string }[],
  contentSelector: string,
) {
  for (const el of document.querySelectorAll("mark[data-slot='highlight']")) {
    const parent = el.parentNode;
    if (parent) {
      parent.replaceChild(document.createTextNode(el.textContent || ""), el);
      parent.normalize();
    }
  }
  if (quotes.length === 0) return;

  const sel = window.getSelection();
  if (!sel) return;

  for (const q of quotes) {
    if (!q.text) continue;
    sel.removeAllRanges();

    const found = (
      window as unknown as { find: (...args: unknown[]) => boolean }
    ).find(q.text, false, false, true, false, true, false);
    if (!found) continue;

    const range = sel.getRangeAt(0);
    const container = document.querySelector(contentSelector);
    if (container && !container.contains(range.commonAncestorContainer))
      continue;

    const mark = document.createElement("mark");
    mark.setAttribute("data-slot", "highlight");
    if (q.commentId) mark.setAttribute("data-comment-id", q.commentId);

    mark.className = [
      "rounded-sm px-0 py-px text-inherit underline cursor-pointer",
      "decoration-2 underline-offset-[3px]",
      "transition-[background,text-decoration-color] duration-150 ease-in-out",
    ].join(" ");

    if (q.active) {
      mark.setAttribute("data-active", "");
      mark.style.background =
        "var(--highlight-bg-active, oklch(0.85 0.15 85 / 0.25))";
      mark.style.textDecorationColor =
        "var(--highlight-underline-active, oklch(0.75 0.15 85 / 0.6))";
    } else {
      mark.style.background = "var(--highlight-bg, oklch(0.85 0.15 85 / 0.1))";
      mark.style.textDecorationColor =
        "var(--highlight-underline, oklch(0.75 0.15 85 / 0.25))";
    }

    try {
      range.surroundContents(mark);
    } catch {
      const fragment = range.extractContents();
      mark.appendChild(fragment);
      range.insertNode(mark);
    }
  }
  sel.removeAllRanges();
}

export function PageComments({
  pageId,
  adapter: adapterProp,
  contentSelector = ".prose",
  user,
  keyboard = true,
  highlightStyle = "notion",
  onComment,
  onResolve,
  pollInterval = 10000,
}: PageCommentsProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [adapter] = useState(() => adapterProp ?? inMemoryAdapter());
  const [comments, setComments] = useState<PageCommentsComment[]>([]);
  const [placing, setPlacing] = useState(false);
  const [pendingPos, setPendingPos] = useState<{ x: number; y: number } | null>(
    null,
  );
  const [pendingText, setPendingText] = useState("");
  const [pendingQuote, setPendingQuote] = useState("");
  const [activeComment, setActiveComment] = useState<string | null>(null);
  const [selectionPopup, setSelectionPopup] = useState<SelectionPopup | null>(
    null,
  );
  const [replyingTo, setReplyingTo] = useState<string | null>(null);
  const [replyText, setReplyText] = useState("");
  const [focusedIdx, setFocusedIdx] = useState(-1);
  const inputRef = useRef<HTMLInputElement>(null);
  const replyInputRef = useRef<HTMLInputElement>(null);
  const selectionPopupRef = useRef<HTMLDivElement>(null);
  const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);

  const color = getColor(user.name, user.color);
  const avatar = user.avatar ?? user.name[0]?.toUpperCase() ?? "?";

  const unresolvedComments = comments
    .filter((c) => !c.resolved)
    .sort((a, b) => a.y - b.y);
  const resolvedComments = comments
    .filter((c) => c.resolved)
    .sort((a, b) => a.y - b.y);
  const navComments = [...unresolvedComments, ...resolvedComments];

  const getParentRect = useCallback(() => {
    const el = portalTarget ?? containerRef.current?.parentElement;
    return (
      el?.getBoundingClientRect() ?? {
        left: 0,
        top: 0,
        width: document.documentElement.clientWidth,
        height: document.documentElement.clientHeight,
      }
    );
  }, [portalTarget]);

  const toRelativeCoords = useCallback(
    (clientX: number, clientY: number) => {
      const rect = getParentRect();
      const el = portalTarget ?? containerRef.current?.parentElement;
      const xPercent = ((clientX - rect.left) / rect.width) * 100;
      const yPx = clientY - rect.top + (el?.scrollTop ?? 0);
      return { x: xPercent, y: yPx };
    },
    [getParentRect, portalTarget],
  );

  const getSelectionRelative = useCallback((): SelectionPopup | null => {
    const sel = window.getSelection();
    if (!sel || sel.isCollapsed || !sel.toString().trim()) return null;
    const range = sel.getRangeAt(0);
    const rangeRect = range.getBoundingClientRect();
    const rect = getParentRect();
    return {
      x: rangeRect.left + rangeRect.width / 2 - rect.left,
      y: rangeRect.top - rect.top,
      text: sel.toString().trim(),
    };
  }, [getParentRect]);

  const fetchComments = useCallback(async () => {
    const result = await adapter.getComments(pageId);
    setComments(result);
  }, [adapter, pageId]);

  useEffect(() => {
    fetchComments();
    const interval = setInterval(fetchComments, pollInterval);
    return () => clearInterval(interval);
  }, [fetchComments, pollInterval]);

  useEffect(() => {
    if (pendingPos && inputRef.current) inputRef.current.focus();
  }, [pendingPos]);

  const stateRef = useRef({ focusedIdx, navComments, placing });
  stateRef.current = { focusedIdx, navComments, placing };

  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      const target = e.target as HTMLElement;
      const highlightMark = target.closest("mark[data-comment-id]");
      if (highlightMark) {
        const cid = highlightMark.getAttribute("data-comment-id");
        if (cid) {
          setActiveComment(cid);
          const nc = stateRef.current.navComments;
          setFocusedIdx(nc.findIndex((c) => c.id === cid));
          return;
        }
      }
      if (
        target.closest("[data-slot='pin']") ||
        target.closest("[data-slot='input']")
      )
        return;
      if (target.closest("[data-slot='toolbar']")) return;
      if (stateRef.current.focusedIdx !== -1) {
        setActiveComment(null);
        setFocusedIdx(-1);
      }
    };
    document.addEventListener("click", handleClick);
    return () => document.removeEventListener("click", handleClick);
  }, []);

  useEffect(() => {
    if (highlightStyle === "none") return;
    const quotes: { text: string; active: boolean; commentId?: string }[] = [];
    for (const c of comments) {
      if (c.quote && !c.resolved) {
        quotes.push({
          text: c.quote,
          active: c.id === activeComment,
          commentId: c.id,
        });
      }
    }
    if (pendingQuote) {
      quotes.push({ text: pendingQuote, active: true });
    }
    highlightQuotes(quotes, contentSelector);
    return () => highlightQuotes([], contentSelector);
  }, [activeComment, comments, pendingQuote, contentSelector, highlightStyle]);

  useEffect(() => {
    if (replyingTo && replyInputRef.current) replyInputRef.current.focus();
  }, [replyingTo]);

  useEffect(() => {
    const handleMouseUp = () => {
      setTimeout(() => {
        const info = getSelectionRelative();
        if (info && !pendingPos) setSelectionPopup(info);
        else setSelectionPopup(null);
      }, 10);
    };
    const handleMouseDown = (e: MouseEvent) => {
      if (selectionPopupRef.current?.contains(e.target as Node)) return;
      setSelectionPopup(null);
    };
    document.addEventListener("mouseup", handleMouseUp);
    document.addEventListener("mousedown", handleMouseDown);
    return () => {
      document.removeEventListener("mouseup", handleMouseUp);
      document.removeEventListener("mousedown", handleMouseDown);
    };
  }, [pendingPos, getSelectionRelative]);

  const scrollToComment = useCallback(
    (yInContainer: number) => {
      const rect = getParentRect();
      const absY = rect.top + window.scrollY + yInContainer;
      window.scrollTo({
        top: absY - window.innerHeight / 3,
        behavior: "smooth",
      });
    },
    [getParentRect],
  );

  useEffect(() => {
    if (!keyboard) return;
    const handleKeyDown = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement;
      if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
      const { focusedIdx: fi, navComments: nc, placing: pl } = stateRef.current;

      if (e.key === "ArrowDown" && nc.length > 0) {
        e.preventDefault();
        const next = fi < nc.length - 1 ? fi + 1 : 0;
        setFocusedIdx(next);
        setActiveComment(nc[next].id);
        scrollToComment(nc[next].y);
      } else if (e.key === "ArrowUp" && nc.length > 0) {
        e.preventDefault();
        const prev = fi > 0 ? fi - 1 : nc.length - 1;
        setFocusedIdx(prev);
        setActiveComment(nc[prev].id);
        scrollToComment(nc[prev].y);
      } else if (e.key === "c" || e.key === "C") {
        e.preventDefault();
        setPlacing(!pl);
        setPendingPos(null);
        setPendingQuote("");
        setActiveComment(null);
        setSelectionPopup(null);
      } else if (e.key === "Escape") {
        setActiveComment(null);
        setFocusedIdx(-1);
        setPendingPos(null);
        setPendingText("");
        setPendingQuote("");
        if (pl) setPlacing(false);
      }
    };
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [keyboard, scrollToComment]);

  const navigateComments = (dir: "up" | "down") => {
    if (navComments.length === 0) return;
    let next: number;
    if (dir === "down") {
      next = focusedIdx < navComments.length - 1 ? focusedIdx + 1 : 0;
    } else {
      next = focusedIdx > 0 ? focusedIdx - 1 : navComments.length - 1;
    }
    setFocusedIdx(next);
    setActiveComment(navComments[next].id);
    scrollToComment(navComments[next].y);
  };

  const startCommentFromSelection = (selInfo: SelectionPopup) => {
    const rect = getParentRect();
    const el = portalTarget ?? containerRef.current?.parentElement;
    const xPercent = (selInfo.x / rect.width) * 100;
    const yPx = selInfo.y + (el?.scrollTop ?? 0);
    setPendingPos({ x: xPercent, y: yPx });
    setPendingQuote(selInfo.text);
    setPendingText("");
    setSelectionPopup(null);
    setActiveComment(null);
    window.getSelection()?.removeAllRanges();
    if (!placing) setPlacing(true);
  };

  const handleOverlayClick = (e: React.MouseEvent) => {
    if (!placing) return;
    if ((e.target as HTMLElement).closest("[data-slot='pin']")) return;
    if ((e.target as HTMLElement).closest("[data-slot='input']")) return;

    const sel = window.getSelection();
    if (sel && !sel.isCollapsed && sel.toString().trim()) {
      const info = getSelectionRelative();
      if (info) {
        startCommentFromSelection(info);
        return;
      }
    }

    const { x, y } = toRelativeCoords(e.clientX, e.clientY);
    setPendingPos({ x, y });
    setPendingQuote("");
    setPendingText("");
    setActiveComment(null);
  };

  const submitComment = async () => {
    if (!pendingText.trim() || !pendingPos) return;
    const newComment: NewComment = {
      name: user.name,
      text: pendingText.trim(),
      quote: pendingQuote || undefined,
      x: pendingPos.x,
      y: pendingPos.y,
    };
    const comment = await adapter.addComment(pageId, newComment);
    setComments((prev) => [...prev, comment]);
    setPendingPos(null);
    setPendingText("");
    setPendingQuote("");
    if (comment.quote) setActiveComment(comment.id);
    onComment?.(comment);
  };

  const toggleResolve = async (commentId: string) => {
    const updated = await adapter.updateComment(pageId, commentId, {
      action: "resolve",
    });
    if (updated) {
      setComments((prev) =>
        prev.map((c) => (c.id === commentId ? updated : c)),
      );
      onResolve?.(commentId);
    }
  };

  const submitReply = async (commentId: string) => {
    if (!replyText.trim()) return;
    const updated = await adapter.updateComment(pageId, commentId, {
      action: "reply",
      name: user.name,
      text: replyText.trim(),
    });
    if (updated) {
      setComments((prev) =>
        prev.map((c) => (c.id === commentId ? updated : c)),
      );
    }
    setReplyText("");
    setReplyingTo(null);
  };

  const deleteComment = async (commentId: string) => {
    await adapter.deleteComment(pageId, commentId, user.name);
    setComments((prev) => prev.filter((c) => c.id !== commentId));
    setActiveComment(null);
  };

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const parent = el.parentElement;
    if (parent) {
      const pos = getComputedStyle(parent).position;
      if (pos === "static") parent.style.position = "relative";
      setPortalTarget(parent);
    }
  }, []);

  const overlayContent = portalTarget && (
    <>
      {/* Comment overlay */}
      {placing && (
        <button
          type="button"
          onClick={handleOverlayClick}
          className="absolute inset-0 z-[9990] border-none bg-transparent p-0 m-0"
          style={{ cursor: makeCursor(color) }}
        />
      )}

      {/* Text selection tooltip */}
      {selectionPopup && !placing && !pendingPos && (
        <div
          ref={selectionPopupRef}
          className="absolute z-[9997] animate-in fade-in zoom-in-95 duration-150"
          style={{
            left: selectionPopup.x,
            top: selectionPopup.y - 8,
            transform: "translate(-50%, -100%)",
          }}
        >
          <button
            type="button"
            onClick={() => startCommentFromSelection(selectionPopup)}
            className="flex items-center gap-1.5 whitespace-nowrap rounded-lg border bg-popover px-2.5 py-1.5 text-xs text-muted-foreground shadow-lg transition-colors hover:border-primary hover:text-foreground"
          >
            <svg
              width="12"
              height="12"
              viewBox="0 0 16 16"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.5"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden="true"
            >
              <path d="M2 4h12M2 8h8M2 12h10" />
            </svg>
            Comment on selection
          </button>
          <div className="absolute -bottom-1 left-1/2 -ml-1 size-2 rotate-45 border-b border-r bg-popover" />
        </div>
      )}

      {/* Comment pins */}
      {comments.map((c) => {
        const isActive = activeComment === c.id;
        const cColor = getColor(c.name);
        return (
          <div
            key={c.id}
            data-slot="pin"
            className="absolute -translate-x-3 -translate-y-3"
            style={{
              left: `${c.x}%`,
              top: c.y,
              zIndex: isActive ? 9996 : 9995,
            }}
          >
            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation();
                const newActive = isActive ? null : c.id;
                setActiveComment(newActive);
                setFocusedIdx(
                  newActive
                    ? navComments.findIndex((nc) => nc.id === c.id)
                    : -1,
                );
                setReplyingTo(null);
                setReplyText("");
              }}
              className={`flex size-6 items-center justify-center rounded-full border-2 text-[10px] font-semibold shadow-md transition-transform hover:scale-[1.2] ${c.resolved ? "border-emerald-300 text-emerald-950 ring-1 ring-emerald-400/50" : "border-background text-white"}`}
              style={{
                background: c.resolved ? "#34d399" : cColor,
              }}
            >
              {c.resolved ? "\u2713" : c.name[0].toUpperCase()}
            </button>

            {isActive && (
              <div className="absolute top-[30px] left-0 min-w-[260px] max-w-[320px] overflow-hidden rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-150">
                <div className="flex items-center gap-1.5 px-3 pt-2.5 pb-2">
                  <div
                    className="flex size-[18px] items-center justify-center rounded-full text-[9px] font-semibold text-white"
                    style={{ background: cColor }}
                  >
                    {c.name[0].toUpperCase()}
                  </div>
                  <span className="text-xs font-medium text-foreground">
                    {c.name}
                  </span>
                  <span className="ml-auto text-[11px] text-muted-foreground">
                    {timeAgo(c.timestamp)}
                  </span>
                </div>

                <p className="m-0 px-3 pb-2 text-[13px] leading-relaxed text-muted-foreground">
                  {c.text}
                </p>

                {c.replies && c.replies.length > 0 && (
                  <div className="border-t">
                    {c.replies.map((r) => (
                      <div key={r.id} className="border-b px-3 py-2">
                        <div className="mb-0.5 flex items-center gap-1">
                          <div
                            className="flex size-3.5 items-center justify-center rounded-full text-[7px] font-semibold text-white"
                            style={{ background: getColor(r.name) }}
                          >
                            {r.name[0].toUpperCase()}
                          </div>
                          <span className="text-[11px] font-medium text-foreground">
                            {r.name}
                          </span>
                          <span className="ml-auto text-[10px] text-muted-foreground">
                            {timeAgo(r.timestamp)}
                          </span>
                        </div>
                        <p className="m-0 pl-[18px] text-xs leading-snug text-muted-foreground">
                          {r.text}
                        </p>
                      </div>
                    ))}
                  </div>
                )}

                {replyingTo === c.id && (
                  <div className="border-t px-3 py-2">
                    <input
                      ref={replyInputRef}
                      type="text"
                      placeholder="Reply..."
                      value={replyText}
                      onChange={(e) => setReplyText(e.target.value)}
                      onKeyDown={(e) => {
                        if (e.key === "Enter") submitReply(c.id);
                        if (e.key === "Escape") {
                          setReplyingTo(null);
                          setReplyText("");
                        }
                      }}
                      className="w-full border-none bg-transparent p-0 text-xs text-foreground outline-none placeholder:text-muted-foreground"
                    />
                  </div>
                )}

                <div className="flex border-t">
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation();
                      toggleResolve(c.id);
                    }}
                    className="flex flex-1 items-center justify-center gap-1 border-r py-[7px] text-[11px] text-muted-foreground transition-colors hover:text-green-500"
                    style={{ color: c.resolved ? "#30A46C" : undefined }}
                  >
                    <svg
                      width="12"
                      height="12"
                      viewBox="0 0 16 16"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M3 8.5l3.5 3.5 6.5-8" />
                    </svg>
                    {c.resolved ? "Reopen" : "Resolve"}
                  </button>
                  <button
                    type="button"
                    onClick={(e) => {
                      e.stopPropagation();
                      setReplyingTo(replyingTo === c.id ? null : c.id);
                      setReplyText("");
                    }}
                    className={`flex flex-1 items-center justify-center gap-1 py-[7px] text-[11px] text-muted-foreground transition-colors hover:text-foreground ${c.name === user.name ? "border-r" : ""}`}
                  >
                    <svg
                      width="12"
                      height="12"
                      viewBox="0 0 16 16"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="1.5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M14 10c0 .55-.2 1.05-.59 1.41-.38.37-.88.59-1.41.59H5l-3 3V4c0-.55.2-1.05.59-1.41C2.97 2.2 3.45 2 4 2h8c.55 0 1.05.2 1.41.59.37.38.59.88.59 1.41v6z" />
                    </svg>
                    Reply
                  </button>
                  {c.name === user.name && (
                    <button
                      type="button"
                      onClick={(e) => {
                        e.stopPropagation();
                        deleteComment(c.id);
                      }}
                      className="flex flex-1 items-center justify-center gap-1 py-[7px] text-[11px] text-muted-foreground transition-colors hover:text-destructive"
                    >
                      <svg
                        width="12"
                        height="12"
                        viewBox="0 0 16 16"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.5"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                      >
                        <path d="M2 4h12M5.33 4V2.67a1.33 1.33 0 011.34-1.34h2.66a1.33 1.33 0 011.34 1.34V4M12.67 4v9.33a1.33 1.33 0 01-1.34 1.34H4.67a1.33 1.33 0 01-1.34-1.34V4" />
                      </svg>
                      Delete
                    </button>
                  )}
                </div>
              </div>
            )}
          </div>
        );
      })}

      {/* Pending comment input */}
      {pendingPos && (
        <div
          data-slot="input"
          className="absolute -translate-x-3 -translate-y-3 z-[9996]"
          style={{ left: `${pendingPos.x}%`, top: pendingPos.y }}
        >
          <div
            className="flex size-6 items-center justify-center rounded-full border-2 border-background text-[10px] font-semibold text-white shadow-md"
            style={{ background: color }}
          >
            {avatar}
          </div>
          <div
            className="absolute top-7 left-0 min-w-[240px] rounded-lg border bg-popover p-2 shadow-lg animate-in fade-in zoom-in-95 duration-150"
            style={{ borderColor: color }}
          >
            <input
              ref={inputRef}
              type="text"
              placeholder="Add a comment..."
              value={pendingText}
              onChange={(e) => setPendingText(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter") submitComment();
                if (e.key === "Escape") {
                  setPendingPos(null);
                  setPendingText("");
                  setPendingQuote("");
                }
              }}
              className="w-full border-none bg-transparent p-0 text-[13px] text-foreground outline-none placeholder:text-muted-foreground"
            />
            <div className="mt-2 flex justify-end gap-1">
              <button
                type="button"
                onClick={() => {
                  setPendingPos(null);
                  setPendingText("");
                  setPendingQuote("");
                }}
                className="rounded px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
              >
                Cancel
              </button>
              <button
                type="button"
                onClick={submitComment}
                disabled={!pendingText.trim()}
                className="rounded px-2.5 py-0.5 text-xs font-medium text-white transition-opacity disabled:opacity-40"
                style={{ background: color }}
              >
                Post
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );

  return (
    <div ref={containerRef} data-slot="page-comments">
      {/* Floating toolbar */}
      <div
        data-slot="toolbar"
        className="sticky bottom-6 z-[9998] mx-auto w-fit flex items-center gap-1.5 rounded-xl border bg-popover px-2.5 py-1.5 shadow-lg transition-colors"
        style={{ borderColor: placing ? color : undefined }}
      >
        <div
          className="flex size-[22px] shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
          style={{ background: color }}
        >
          {avatar}
        </div>
        <span className="text-[13px] text-muted-foreground">{user.name}</span>
        <div className="mx-0.5 h-4 w-px bg-border" />
        <button
          type="button"
          onClick={() => {
            setPlacing(!placing);
            setPendingPos(null);
            setPendingQuote("");
            setActiveComment(null);
            setSelectionPopup(null);
          }}
          className="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[13px] transition-colors"
          style={{
            background: placing ? color : "transparent",
            color: placing ? "#fff" : undefined,
          }}
        >
          <svg
            width="14"
            height="14"
            viewBox="0 0 16 16"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden="true"
          >
            <path d="M3 2l10 5.5L8 9l-1.5 5z" />
          </svg>
          {placing ? "Click anywhere..." : "Comment"}
        </button>
        <div className="mx-0.5 h-4 w-px bg-border" />
        <span className="min-w-5 text-center text-xs text-muted-foreground">
          {comments.length}
        </span>
        {comments.length > 0 && (
          <>
            <button
              type="button"
              onClick={() => navigateComments("up")}
              title="Previous comment (Arrow Up)"
              className="flex items-center justify-center rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
            >
              <svg
                width="14"
                height="14"
                viewBox="0 0 16 16"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M8 3v10M3 8l5-5 5 5" />
              </svg>
            </button>
            <button
              type="button"
              onClick={() => navigateComments("down")}
              title="Next comment (Arrow Down)"
              className="flex items-center justify-center rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
            >
              <svg
                width="14"
                height="14"
                viewBox="0 0 16 16"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.5"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M8 13V3M3 8l5 5 5-5" />
              </svg>
            </button>
          </>
        )}
      </div>

      {portalTarget && createPortal(overlayContent, portalTarget)}

      <style>{`
				mark[data-slot="highlight"]:not([data-active]):hover {
					background: var(--highlight-bg-hover, oklch(0.85 0.15 85 / 0.18)) !important;
					text-decoration-color: var(--highlight-underline-hover, oklch(0.75 0.15 85 / 0.45)) !important;
				}
				mark[data-slot="highlight"][data-active]:hover {
					background: var(--highlight-bg-active-hover, oklch(0.85 0.15 85 / 0.32)) !important;
					text-decoration-color: var(--highlight-underline-active-hover, oklch(0.75 0.15 85 / 0.7)) !important;
				}
			`}</style>
    </div>
  );
}
registry/default/blocks/collaboration/page-comments/components/elements/page-comments-adapters.ts
"use client";

export interface PageCommentsReply {
  id: string;
  name: string;
  text: string;
  timestamp: number;
}

export interface PageCommentsComment {
  id: string;
  name: string;
  text: string;
  quote?: string;
  x: number;
  y: number;
  timestamp: number;
  resolved: boolean;
  replies?: PageCommentsReply[];
}

export interface NewComment {
  name: string;
  text: string;
  quote?: string;
  x: number;
  y: number;
}

export interface PageCommentsAdapter {
  getComments(pageId: string): Promise<PageCommentsComment[]>;
  addComment(pageId: string, comment: NewComment): Promise<PageCommentsComment>;
  updateComment(
    pageId: string,
    commentId: string,
    data:
      | { action: "resolve" }
      | { action: "reply"; name: string; text: string },
  ): Promise<PageCommentsComment | null>;
  deleteComment(pageId: string, commentId: string, name: string): Promise<void>;
}

export function inMemoryAdapter(): PageCommentsAdapter {
  const store = new Map<string, PageCommentsComment[]>();

  return {
    async getComments(pageId) {
      return store.get(pageId) ?? [];
    },
    async addComment(pageId, data) {
      const comments = store.get(pageId) ?? [];
      const comment: PageCommentsComment = {
        id: Math.random().toString(36).slice(2, 10),
        name: data.name,
        text: data.text,
        ...(data.quote ? { quote: data.quote } : {}),
        x: data.x,
        y: data.y,
        timestamp: Date.now(),
        resolved: false,
      };
      comments.push(comment);
      store.set(pageId, comments);
      return comment;
    },
    async updateComment(pageId, commentId, data) {
      const comments = store.get(pageId) ?? [];
      const idx = comments.findIndex((c) => c.id === commentId);
      if (idx === -1) return null;

      if (data.action === "resolve") {
        comments[idx].resolved = !comments[idx].resolved;
      } else if (data.action === "reply") {
        if (!comments[idx].replies) comments[idx].replies = [];
        comments[idx].replies?.push({
          id: Math.random().toString(36).slice(2, 10),
          name: data.name,
          text: data.text,
          timestamp: Date.now(),
        });
      }
      store.set(pageId, comments);
      return comments[idx];
    },
    async deleteComment(pageId, commentId) {
      const comments = store.get(pageId) ?? [];
      store.set(
        pageId,
        comments.filter((c) => c.id !== commentId),
      );
    },
  };
}
registry/default/blocks/collaboration/page-comments/components/elements/page-comments-kv.ts
import type {
  PageCommentsAdapter,
  PageCommentsComment,
} from "./page-comments-adapters";

interface KvAdapterOptions {
  url: string;
  token: string;
  prefix?: string;
}

export function kvAdapter({
  url,
  token,
  prefix = "page-comments",
}: KvAdapterOptions): PageCommentsAdapter {
  async function kvFetch(method: string, args: unknown[]) {
    const res = await fetch(`${url}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify([method, ...args]),
    });
    const data = await res.json();
    return data.result;
  }

  async function getAll(pageId: string): Promise<PageCommentsComment[]> {
    const result = await kvFetch("get", [`${prefix}:${pageId}`]);
    if (!result) return [];
    return typeof result === "string" ? JSON.parse(result) : result;
  }

  async function setAll(pageId: string, comments: PageCommentsComment[]) {
    await kvFetch("set", [`${prefix}:${pageId}`, JSON.stringify(comments)]);
  }

  return {
    async getComments(pageId) {
      return getAll(pageId);
    },
    async addComment(pageId, data) {
      const comments = await getAll(pageId);
      const comment: PageCommentsComment = {
        id: crypto.randomUUID().slice(0, 8),
        name: data.name,
        text: data.text,
        ...(data.quote ? { quote: data.quote } : {}),
        x: data.x,
        y: data.y,
        timestamp: Date.now(),
        resolved: false,
      };
      comments.push(comment);
      await setAll(pageId, comments);
      return comment;
    },
    async updateComment(pageId, commentId, data) {
      const comments = await getAll(pageId);
      const idx = comments.findIndex((c) => c.id === commentId);
      if (idx === -1) return null;

      if (data.action === "resolve") {
        comments[idx].resolved = !comments[idx].resolved;
      } else if (data.action === "reply") {
        if (!comments[idx].replies) comments[idx].replies = [];
        comments[idx].replies?.push({
          id: crypto.randomUUID().slice(0, 8),
          name: data.name,
          text: data.text,
          timestamp: Date.now(),
        });
      }
      await setAll(pageId, comments);
      return comments[idx];
    },
    async deleteComment(pageId, commentId) {
      const comments = await getAll(pageId);
      await setAll(
        pageId,
        comments.filter((c) => c.id !== commentId),
      );
    },
  };
}
registry/default/blocks/collaboration/page-comments/components/elements/page-comments-upstash.ts
import type {
  PageCommentsAdapter,
  PageCommentsComment,
} from "./page-comments-adapters";

interface UpstashAdapterOptions {
  url: string;
  token: string;
  prefix?: string;
}

export function upstashAdapter({
  url,
  token,
  prefix = "page-comments",
}: UpstashAdapterOptions): PageCommentsAdapter {
  async function redis(command: string, ...args: string[]) {
    const res = await fetch(`${url}/${command}/${args.join("/")}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const data = await res.json();
    return data.result;
  }

  async function getAll(pageId: string): Promise<PageCommentsComment[]> {
    const result = await redis("get", `${prefix}:${pageId}`);
    if (!result) return [];
    return typeof result === "string" ? JSON.parse(result) : result;
  }

  async function setAll(pageId: string, comments: PageCommentsComment[]) {
    await redis("set", `${prefix}:${pageId}`, JSON.stringify(comments));
  }

  return {
    async getComments(pageId) {
      return getAll(pageId);
    },
    async addComment(pageId, data) {
      const comments = await getAll(pageId);
      const comment: PageCommentsComment = {
        id: crypto.randomUUID().slice(0, 8),
        name: data.name,
        text: data.text,
        ...(data.quote ? { quote: data.quote } : {}),
        x: data.x,
        y: data.y,
        timestamp: Date.now(),
        resolved: false,
      };
      comments.push(comment);
      await setAll(pageId, comments);
      return comment;
    },
    async updateComment(pageId, commentId, data) {
      const comments = await getAll(pageId);
      const idx = comments.findIndex((c) => c.id === commentId);
      if (idx === -1) return null;

      if (data.action === "resolve") {
        comments[idx].resolved = !comments[idx].resolved;
      } else if (data.action === "reply") {
        if (!comments[idx].replies) comments[idx].replies = [];
        comments[idx].replies?.push({
          id: crypto.randomUUID().slice(0, 8),
          name: data.name,
          text: data.text,
          timestamp: Date.now(),
        });
      }
      await setAll(pageId, comments);
      return comments[idx];
    },
    async deleteComment(pageId, commentId) {
      const comments = await getAll(pageId);
      await setAll(
        pageId,
        comments.filter((c) => c.id !== commentId),
      );
    },
  };
}
registry/default/blocks/collaboration/page-comments/components/elements/page-comments-supabase.ts
import type {
  PageCommentsAdapter,
  PageCommentsComment,
} from "./page-comments-adapters";

interface SupabaseAdapterOptions {
  client: {
    // biome-ignore lint/suspicious/noExplicitAny: Supabase client returns dynamic query builders
    from: (table: string) => any;
  };
  table?: string;
}

export function supabaseAdapter({
  client,
  table = "page_comments",
}: SupabaseAdapterOptions): PageCommentsAdapter {
  return {
    async getComments(pageId) {
      const { data } = await client
        .from(table)
        .select("data")
        .eq("page_id", pageId)
        .single();
      return data?.data ?? [];
    },
    async addComment(pageId, newComment) {
      const comments = await this.getComments(pageId);
      const comment: PageCommentsComment = {
        id: crypto.randomUUID().slice(0, 8),
        name: newComment.name,
        text: newComment.text,
        ...(newComment.quote ? { quote: newComment.quote } : {}),
        x: newComment.x,
        y: newComment.y,
        timestamp: Date.now(),
        resolved: false,
      };
      const updated = [...comments, comment];
      await client
        .from(table)
        .upsert({ page_id: pageId, data: updated }, { onConflict: "page_id" });
      return comment;
    },
    async updateComment(pageId, commentId, data) {
      const comments = await this.getComments(pageId);
      const idx = comments.findIndex(
        (c: PageCommentsComment) => c.id === commentId,
      );
      if (idx === -1) return null;

      if (data.action === "resolve") {
        comments[idx].resolved = !comments[idx].resolved;
      } else if (data.action === "reply") {
        if (!comments[idx].replies) comments[idx].replies = [];
        comments[idx].replies?.push({
          id: crypto.randomUUID().slice(0, 8),
          name: data.name,
          text: data.text,
          timestamp: Date.now(),
        });
      }
      await client
        .from(table)
        .upsert({ page_id: pageId, data: comments }, { onConflict: "page_id" });
      return comments[idx];
    },
    async deleteComment(pageId, commentId) {
      const comments = await this.getComments(pageId);
      const filtered = comments.filter(
        (c: PageCommentsComment) => c.id !== commentId,
      );
      await client
        .from(table)
        .upsert({ page_id: pageId, data: filtered }, { onConflict: "page_id" });
    },
  };
}
registry/default/blocks/collaboration/page-comments/components/elements/page-comments-redis.ts
import type {
  PageCommentsAdapter,
  PageCommentsComment,
} from "./page-comments-adapters";

interface RedisAdapterOptions {
  client: {
    get: (key: string) => Promise<string | null>;
    set: (key: string, value: string) => Promise<unknown>;
  };
  prefix?: string;
}

export function redisAdapter({
  client,
  prefix = "page-comments",
}: RedisAdapterOptions): PageCommentsAdapter {
  async function getAll(pageId: string): Promise<PageCommentsComment[]> {
    const raw = await client.get(`${prefix}:${pageId}`);
    if (!raw) return [];
    return JSON.parse(raw);
  }

  async function setAll(pageId: string, comments: PageCommentsComment[]) {
    await client.set(`${prefix}:${pageId}`, JSON.stringify(comments));
  }

  return {
    async getComments(pageId) {
      return getAll(pageId);
    },
    async addComment(pageId, data) {
      const comments = await getAll(pageId);
      const comment: PageCommentsComment = {
        id: crypto.randomUUID().slice(0, 8),
        name: data.name,
        text: data.text,
        ...(data.quote ? { quote: data.quote } : {}),
        x: data.x,
        y: data.y,
        timestamp: Date.now(),
        resolved: false,
      };
      comments.push(comment);
      await setAll(pageId, comments);
      return comment;
    },
    async updateComment(pageId, commentId, data) {
      const comments = await getAll(pageId);
      const idx = comments.findIndex((c) => c.id === commentId);
      if (idx === -1) return null;

      if (data.action === "resolve") {
        comments[idx].resolved = !comments[idx].resolved;
      } else if (data.action === "reply") {
        if (!comments[idx].replies) comments[idx].replies = [];
        comments[idx].replies?.push({
          id: crypto.randomUUID().slice(0, 8),
          name: data.name,
          text: data.text,
          timestamp: Date.now(),
        });
      }
      await setAll(pageId, comments);
      return comments[idx];
    },
    async deleteComment(pageId, commentId) {
      const comments = await getAll(pageId);
      await setAll(
        pageId,
        comments.filter((c) => c.id !== commentId),
      );
    },
  };
}

More from elements

Browse all components in this library →