From 4a6de2d4cc46319ec16042c2c666d042e4815f61 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Mon, 6 Apr 2026 10:26:34 +0700
Subject: [PATCH 1/4] fix: show comment cursor icon when comment tool is active
Add a custom SVG speech bubble cursor (accent blue) that appears whenever
the comment tool is selected. The cursor persists over all child elements
including screen nodes, hotspots, and resize handles. Space+drag panning
still overrides to grab/grabbing as expected.
---
src/components/CanvasArea.jsx | 15 +++++++++++++++
src/hooks/useCanvasMouseHandlers.js | 5 ++++-
2 files changed, 19 insertions(+), 1 deletion(-)
diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx
index 27da8da..b254404 100644
--- a/src/components/CanvasArea.jsx
+++ b/src/components/CanvasArea.jsx
@@ -62,9 +62,16 @@ export function CanvasArea({
onTemplates,
// MCP flash
mcpFlashIds,
+ // Comments
+ comments, canComment, onCommentImageClick, onCommentConnectionClick,
}) {
return (
+ <>
+ {activeTool === "comment" && (
+
+ )}
{
if (connectionTypePrompt) { onConnectionTypeNavigate(); return; }
@@ -196,6 +203,12 @@ export function CanvasArea({
isReadOnly={isReadOnly}
onFormSummary={onFormSummary}
mcpFlash={mcpFlashIds?.has(screen.id)}
+ commentPins={(comments || []).filter(
+ (c) => c.screenId === screen.id && c.targetType === "screen" && !c.resolved
+ )}
+ onCommentImageClick={onCommentImageClick}
+ selectedCommentId={null}
+ onCommentPinClick={null}
/>
))}
{stickyNotes.map((note) => (
@@ -564,6 +577,7 @@ export function CanvasArea({
onUpload={handleImageUpload}
onAddBlank={() => addScreenAtCenter()}
isReadOnly={isReadOnly}
+ canComment={canComment}
onAddStickyNote={() => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
@@ -575,5 +589,6 @@ export function CanvasArea({
onAddWireframe={onAddWireframe}
/>
+ >
);
}
diff --git a/src/hooks/useCanvasMouseHandlers.js b/src/hooks/useCanvasMouseHandlers.js
index 82072a5..91332ae 100644
--- a/src/hooks/useCanvasMouseHandlers.js
+++ b/src/hooks/useCanvasMouseHandlers.js
@@ -5,6 +5,7 @@ const resizeCursors = {
nw: "nwse-resize", n: "ns-resize", ne: "nesw-resize", e: "ew-resize",
se: "nwse-resize", s: "ns-resize", sw: "nesw-resize", w: "ew-resize",
};
+const COMMENT_CURSOR = `url("data:image/svg+xml,
@@ -593,6 +620,31 @@ export default function Drawd({ initialRoomCode }) {
const s = screens.find((sc) => sc.id === screenId);
if (s?.wireframe) setWireframeEditor({ screenId, components: s.wireframe.components, viewport: s.wireframe.viewport });
}}
+ comments={comments}
+ canComment={canComment}
+ onCommentImageClick={(e, screenId, xPct, yPct) => {
+ setCommentComposer({
+ targetType: "screen",
+ targetId: screenId,
+ screenId,
+ anchor: { xPct, yPct },
+ clientX: e.clientX,
+ clientY: e.clientY,
+ });
+ }}
+ onCommentConnectionClick={(e, connectionId, t) => {
+ setCommentComposer({
+ targetType: "connection",
+ targetId: connectionId,
+ screenId: null,
+ anchor: { t },
+ clientX: e.clientX,
+ clientY: e.clientY,
+ });
+ }}
+ selectedCommentId={selectedCommentId}
+ onCommentPinClick={(id) => setSelectedCommentId((prev) => (prev === id ? null : id))}
+ onDeselectComment={() => setSelectedCommentId(null)}
/>
{selectedScreenData && (
@@ -681,6 +733,36 @@ export default function Drawd({ initialRoomCode }) {
showTemplateBrowser={showTemplateBrowser}
setShowTemplateBrowser={setShowTemplateBrowser}
onInsertTemplate={onInsertTemplate}
+ showComments={showComments}
+ setShowComments={setShowComments}
+ comments={comments}
+ connections={connections}
+ canModerate={canModerateComments}
+ selfPeerId={collab.isConnected ? collab.selfPeerId : null}
+ selfDisplayName={collab.selfDisplayName}
+ onResolveComment={(id) => resolveComment(id, collab.selfDisplayName || "Anonymous")}
+ onUnresolveComment={unresolveComment}
+ onDeleteComment={deleteComment}
+ selectedCommentId={selectedCommentId}
+ setSelectedCommentId={setSelectedCommentId}
+ commentComposer={commentComposer}
+ setCommentComposer={setCommentComposer}
+ onCommentSubmit={(text) => {
+ if (!commentComposer) return;
+ addComment({
+ text,
+ authorName: collab.selfDisplayName || "Me",
+ authorPeerId: collab.isConnected ? (collab.selfPeerId || null) : null,
+ authorColor: collab.selfColor || "#61afef",
+ targetType: commentComposer.targetType,
+ targetId: commentComposer.targetId,
+ screenId: commentComposer.screenId,
+ anchor: commentComposer.anchor,
+ });
+ setCommentComposer(null);
+ setActiveTool("select");
+ if (!showComments) setShowComments(true);
+ }}
/>
{wireframeEditor && (
diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx
index b254404..a5d9833 100644
--- a/src/components/CanvasArea.jsx
+++ b/src/components/CanvasArea.jsx
@@ -64,6 +64,7 @@ export function CanvasArea({
mcpFlashIds,
// Comments
comments, canComment, onCommentImageClick, onCommentConnectionClick,
+ selectedCommentId, onCommentPinClick, onDeselectComment,
}) {
return (
<>
@@ -207,8 +208,9 @@ export function CanvasArea({
(c) => c.screenId === screen.id && c.targetType === "screen" && !c.resolved
)}
onCommentImageClick={onCommentImageClick}
- selectedCommentId={null}
- onCommentPinClick={null}
+ selectedCommentId={selectedCommentId}
+ onCommentPinClick={onCommentPinClick}
+ onDeselectComment={onDeselectComment}
/>
))}
{stickyNotes.map((note) => (
diff --git a/src/components/CommentComposer.jsx b/src/components/CommentComposer.jsx
new file mode 100644
index 0000000..861f1de
--- /dev/null
+++ b/src/components/CommentComposer.jsx
@@ -0,0 +1,132 @@
+import { useState, useRef, useEffect } from "react";
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+
+/**
+ * Floating inline composer that appears where the user clicked in comment mode.
+ * Props:
+ * clientX, clientY — viewport coordinates for positioning
+ * onSubmit(text) — called when the user confirms
+ * onCancel() — called on Escape or outside click
+ */
+export function CommentComposer({ clientX, clientY, onSubmit, onCancel }) {
+ const [text, setText] = useState("");
+ const textareaRef = useRef(null);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ textareaRef.current?.focus();
+ }, []);
+
+ // Close on outside click
+ useEffect(() => {
+ const onMouseDown = (e) => {
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
+ onCancel();
+ }
+ };
+ document.addEventListener("mousedown", onMouseDown);
+ return () => document.removeEventListener("mousedown", onMouseDown);
+ }, [onCancel]);
+
+ const handleKeyDown = (e) => {
+ if (e.key === "Escape") { e.stopPropagation(); onCancel(); return; }
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ };
+
+ const handleSubmit = () => {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ onSubmit(trimmed);
+ };
+
+ // Keep composer in viewport bounds
+ const COMPOSER_WIDTH = 280;
+ const left = Math.min(clientX, window.innerWidth - COMPOSER_WIDTH - 12);
+ const top = clientY + 12;
+
+ return (
+
+ );
+}
diff --git a/src/components/CommentPin.jsx b/src/components/CommentPin.jsx
new file mode 100644
index 0000000..8f584bf
--- /dev/null
+++ b/src/components/CommentPin.jsx
@@ -0,0 +1,195 @@
+import { useRef, useEffect } from "react";
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+
+function timeAgo(isoString) {
+ const diff = Date.now() - new Date(isoString).getTime();
+ 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`;
+}
+
+/**
+ * A small pin marker rendered on the canvas anchored to a target.
+ * For screen/hotspot targets, position it with left/top absolute inside
+ * the screen image area. For connections, position via SVG transform.
+ */
+export function CommentPin({ comment, count, isSelected, onClick, onDeselect }) {
+ const color = comment.authorColor || "#61afef";
+ const resolved = comment.resolved;
+ const pinRef = useRef(null);
+
+ // Close popover when clicking outside the pin+popover container
+ useEffect(() => {
+ if (!isSelected) return;
+ const handler = (e) => {
+ if (pinRef.current && !pinRef.current.contains(e.target)) {
+ onDeselect?.();
+ }
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [isSelected, onDeselect]);
+
+ // Compute fixed-position for the popover based on the pin's viewport rect
+ const rect = isSelected ? pinRef.current?.getBoundingClientRect() : null;
+ const POPOVER_WIDTH = 240;
+ const popoverLeft = rect
+ ? Math.min(rect.left + rect.width / 2 + 8, window.innerWidth - POPOVER_WIDTH - 12)
+ : 0;
+ const popoverTop = rect ? rect.bottom + 6 : 0;
+
+ return (
+
{ e.stopPropagation(); onClick?.(comment.id); }}
+ style={{
+ position: "absolute",
+ left: `${comment.anchor.xPct}%`,
+ top: `${comment.anchor.yPct}%`,
+ transform: "translate(-50%, -100%)",
+ cursor: "pointer",
+ zIndex: 20,
+ userSelect: "none",
+ }}
+ >
+ {/* Pin body */}
+
+
+ {count > 1 && (
+
+ {count > 9 ? "9+" : count}
+
+ )}
+
+
+
+ {/* Expanded popover — rendered fixed to escape overflow:hidden on ScreenNode */}
+ {isSelected && rect && (
+
e.stopPropagation()}
+ style={{
+ position: "fixed",
+ left: popoverLeft,
+ top: popoverTop,
+ width: POPOVER_WIDTH,
+ background: COLORS.surface,
+ border: `1px solid ${color}`,
+ borderRadius: 10,
+ boxShadow: "0 8px 32px rgba(0,0,0,0.5)",
+ zIndex: Z_INDEX.modal,
+ padding: "10px 12px",
+ }}
+ >
+ {/* Header: avatar + author + time */}
+
+
+
+ {comment.authorName || "Anonymous"}
+
+
+ {timeAgo(comment.createdAt)}
+
+
+
+ {/* Comment text */}
+
+ {comment.text}
+
+
+ {/* Resolved indicator */}
+ {resolved && (
+
+ {comment.resolvedBy ? `Resolved by ${comment.resolvedBy}` : "Resolved"}
+
+ )}
+
+ )}
+
+ );
+}
+
+/**
+ * SVG-based pin for connection comments, positioned at parametric t along a bezier.
+ */
+export function ConnectionCommentPin({ cx, cy, comment, isSelected, onClick }) {
+ const color = comment.authorColor || "#61afef";
+ const resolved = comment.resolved;
+
+ return (
+
{ e.stopPropagation(); onClick?.(comment.id); }}
+ style={{ cursor: "pointer" }}
+ opacity={resolved ? 0.45 : 1}
+ >
+
+
+
+ );
+}
diff --git a/src/components/CommentsPanel.jsx b/src/components/CommentsPanel.jsx
new file mode 100644
index 0000000..937dd4e
--- /dev/null
+++ b/src/components/CommentsPanel.jsx
@@ -0,0 +1,340 @@
+import { useState } from "react";
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+import { TOPBAR_HEIGHT } from "../constants";
+
+const PANEL_WIDTH = 300;
+
+function timeAgo(isoString) {
+ const diff = Date.now() - new Date(isoString).getTime();
+ 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 targetLabel(comment, screens, connections) {
+ if (comment.targetType === "screen") {
+ const s = screens.find((sc) => sc.id === comment.targetId);
+ return s ? s.name || "Unnamed screen" : "Deleted screen";
+ }
+ if (comment.targetType === "hotspot") {
+ for (const s of screens) {
+ const hs = (s.hotspots || []).find((h) => h.id === comment.targetId);
+ if (hs) return `${s.name || "Screen"} › ${hs.label || "Hotspot"}`;
+ }
+ return "Deleted hotspot";
+ }
+ if (comment.targetType === "connection") {
+ const conn = connections.find((c) => c.id === comment.targetId);
+ if (conn) {
+ const from = screens.find((s) => s.id === conn.fromScreenId);
+ const to = screens.find((s) => s.id === conn.toScreenId);
+ return `${from?.name || "?"} → ${to?.name || "?"}`;
+ }
+ return "Deleted connection";
+ }
+ return "Unknown target";
+}
+
+function CommentCard({
+ comment, screens, connections,
+ canModerate, selfPeerId,
+ onResolve, onUnresolve, onDelete,
+ isSelected, onSelect,
+}) {
+ const canDelete = canModerate || (comment.authorPeerId && comment.authorPeerId === selfPeerId);
+
+ return (
+
onSelect(comment.id)}
+ style={{
+ padding: "10px 12px",
+ borderRadius: 8,
+ marginBottom: 6,
+ background: isSelected ? COLORS.accent008 : "rgba(255,255,255,0.02)",
+ border: `1px solid ${isSelected ? COLORS.accent03 : COLORS.border}`,
+ cursor: "pointer",
+ transition: "all 0.12s",
+ opacity: comment.resolved ? 0.6 : 1,
+ }}
+ >
+ {/* Header */}
+
+
+
+ {comment.authorName || "Anonymous"}
+
+
+ {timeAgo(comment.createdAt)}
+
+
+
+ {/* Target context */}
+
+ {targetLabel(comment, screens, connections)}
+
+
+ {/* Comment text */}
+
+ {comment.text}
+
+
+ {/* Resolved note */}
+ {comment.resolved && comment.resolvedBy && (
+
+ Resolved by {comment.resolvedBy}
+
+ )}
+
+ {/* Actions */}
+
+ {!comment.resolved && (
+
+ )}
+ {comment.resolved && canModerate && (
+
+ )}
+ {canDelete && (
+
+ )}
+
+
+ );
+}
+
+export function CommentsPanel({
+ comments, screens, connections,
+ canModerate, selfPeerId, selfDisplayName,
+ onResolve, onUnresolve, onDelete,
+ selectedCommentId, onSelectComment,
+ onClose,
+}) {
+ const [showResolved, setShowResolved] = useState(false);
+
+ const open = comments.filter((c) => !c.resolved);
+ const resolved = comments.filter((c) => c.resolved);
+
+ return (
+
+ {/* Header */}
+
+
+
+ Comments
+
+ {open.length > 0 && (
+
+ {open.length}
+
+ )}
+
+
+
+
+ {/* Comment list */}
+
+ {open.length === 0 && !showResolved && (
+
+ No open comments.
+
+
+ Use the comment tool (C) to add one.
+
+
+ )}
+ {open.map((c) => (
+
+ ))}
+
+ {resolved.length > 0 && (
+
+ )}
+
+ {showResolved && resolved.map((c) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/ModalsLayer.jsx b/src/components/ModalsLayer.jsx
index 6789c61..b24ef39 100644
--- a/src/components/ModalsLayer.jsx
+++ b/src/components/ModalsLayer.jsx
@@ -12,6 +12,8 @@ import { ShareModal } from "./ShareModal";
import { HostLeftModal } from "./HostLeftModal";
import { FormSummaryPanel } from "./FormSummaryPanel";
import { TemplateBrowserModal } from "./TemplateBrowserModal";
+import { CommentsPanel } from "./CommentsPanel";
+import { CommentComposer } from "./CommentComposer";
export function ModalsLayer({
// Hotspot modal
@@ -46,6 +48,14 @@ export function ModalsLayer({
formSummaryScreen, setFormSummaryScreen,
// Template browser
showTemplateBrowser, setShowTemplateBrowser, onInsertTemplate,
+ // Comments
+ showComments, setShowComments,
+ comments, connections,
+ canModerate, selfPeerId, selfDisplayName,
+ onResolveComment, onUnresolveComment, onDeleteComment,
+ selectedCommentId, setSelectedCommentId,
+ commentComposer, setCommentComposer,
+ onCommentSubmit,
}) {
return (
<>
@@ -229,6 +239,32 @@ export function ModalsLayer({
onClose={() => setFormSummaryScreen(null)}
/>
)}
+
+ {showComments && (
+
setShowComments(false)}
+ />
+ )}
+
+ {commentComposer && (
+ setCommentComposer(null)}
+ />
+ )}
>
);
}
diff --git a/src/components/ParticipantsPanel.jsx b/src/components/ParticipantsPanel.jsx
index 6c1c98d..f739e15 100644
--- a/src/components/ParticipantsPanel.jsx
+++ b/src/components/ParticipantsPanel.jsx
@@ -31,7 +31,7 @@ function RoleDropdown({ peer, onSetRole }) {
fontWeight: 500,
}}
>
- {peer.role === "editor" ? "Editor" : "Viewer"} ▾
+ {peer.role === "editor" ? "Editor" : peer.role === "reviewer" ? "Reviewer" : "Viewer"} ▾
{open && (
- {["editor", "viewer"].map((r) => (
+ {[
+ { value: "editor", label: "Editor" },
+ { value: "reviewer", label: "Reviewer (comment-only)" },
+ { value: "viewer", label: "Viewer (read-only)" },
+ ].map(({ value, label }) => (
))}
@@ -91,6 +95,19 @@ function RoleBadge({ role }) {
);
}
+ if (role === "reviewer") {
+ return (
+
+ Reviewer
+
+ );
+ }
if (role === "viewer") {
return (
{
if (isSpaceHeld?.current) return;
- if (e.target.closest(".hotspot-area") || e.target.closest(".hotspot-drag-handle") || e.target.closest(".resize-handle")) return;
+ if (e.target.closest(".hotspot-area") || e.target.closest(".hotspot-drag-handle") || e.target.closest(".resize-handle") || e.target.closest(".comment-pin")) return;
+ if (activeTool === "comment" && onCommentImageClick) {
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ const xPct = ((e.clientX - rect.left) / rect.width) * 100;
+ const yPct = ((e.clientY - rect.top) / rect.height) * 100;
+ onCommentImageClick(e, screen.id, xPct, yPct);
+ return;
+ }
if (screen.imageData && onImageAreaMouseDown) {
onImageAreaMouseDown(e, screen.id);
}
@@ -509,6 +524,18 @@ export function ScreenNode({
}}
/>
)}
+ {/* Comment pins on screen image */}
+ {(commentPins || []).map((comment) => (
+
+
+
+ ))}
>
) : (
(
);
+const CommentIcon = () => (
+
+);
+
const PanIcon = () => (
);
-const TOOLS = [
+const BASE_TOOLS = [
{ id: "select", label: "Select", icon: SelectIcon, key: "V" },
{ id: "pan", label: "Pan", icon: PanIcon, key: "H" },
];
+const COMMENT_TOOL = { id: "comment", label: "Comment", icon: CommentIcon, key: "C" };
const dividerStyle = {
width: 1,
@@ -102,7 +109,8 @@ function ActionButton({ icon: Icon, label, shortcutKey, onClick }) {
const TemplateIcon = () =>
;
-export function ToolBar({ activeTool, onToolChange, onUpload, onAddBlank, onAddStickyNote, onAddWireframe, isReadOnly, onTemplates }) {
+export function ToolBar({ activeTool, onToolChange, onUpload, onAddBlank, onAddStickyNote, onAddWireframe, isReadOnly, onTemplates, canComment }) {
+ const TOOLS = canComment ? [...BASE_TOOLS, COMMENT_TOOL] : BASE_TOOLS;
return (
+
+
+ );
+}
+
function FileMenuIcon() {
return (