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,") 3 21, crosshair`; export function useCanvasMouseHandlers({ // hotspot interaction @@ -410,7 +411,9 @@ export function useCanvasMouseHandlers({ ? "grab" : (spaceHeld && isPanning) ? "grabbing" : spaceHeld ? "grab" - : isPanning ? "grabbing" : "default"; + : isPanning ? "grabbing" + : activeTool === "comment" ? COMMENT_CURSOR + : "default"; return { onCanvasMouseDown, From 7cf098c17f523688a876c3c5b3fb796b66be53b5 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:28:54 +0700 Subject: [PATCH 2/4] feat: add comment tool with canvas annotations and collab sync Introduces a full comment system: pin-based annotations on screens, hotspots, and connections; CommentComposer, CommentPin, and CommentsPanel UI components; useCommentManager CRUD hook with role-based permissions (host/editor/reviewer/viewer); collab broadcast sync for real-time comment updates; MCP server comment tools; and file format support (importFlow/exportFlow). Keyboard shortcut C activates the comment tool. User guide updated. --- mcp-server/src/server.js | 5 + mcp-server/src/state.js | 78 ++++++ mcp-server/src/tools/comment-tools.js | 112 ++++++++ src/Drawd.jsx | 96 ++++++- src/components/CanvasArea.jsx | 6 +- src/components/CommentComposer.jsx | 132 ++++++++++ src/components/CommentPin.jsx | 195 ++++++++++++++ src/components/CommentsPanel.jsx | 340 +++++++++++++++++++++++++ src/components/ModalsLayer.jsx | 36 +++ src/components/ParticipantsPanel.jsx | 35 ++- src/components/ScreenNode.jsx | 29 ++- src/components/ToolBar.jsx | 12 +- src/components/TopBar.jsx | 20 +- src/constants.js | 5 +- src/hooks/useCollabSync.js | 15 +- src/hooks/useCollaboration.js | 14 +- src/hooks/useCommentManager.js | 167 ++++++++++++ src/hooks/useFileActions.js | 8 +- src/hooks/useFilePersistence.js | 12 +- src/hooks/useImportExport.js | 9 +- src/hooks/useKeyboardShortcuts.js | 30 ++- src/hooks/useKeyboardShortcuts.test.js | 101 ++++++++ src/hooks/useScreenManager.js | 16 +- src/pages/docs/userGuide.md | 48 +++- src/utils/buildPayload.js | 6 +- src/utils/buildPayload.test.js | 4 +- src/utils/exportFlow.js | 4 +- src/utils/importFlow.js | 3 + src/utils/importFlow.test.js | 4 +- 29 files changed, 1482 insertions(+), 60 deletions(-) create mode 100644 mcp-server/src/tools/comment-tools.js create mode 100644 src/components/CommentComposer.jsx create mode 100644 src/components/CommentPin.jsx create mode 100644 src/components/CommentsPanel.jsx create mode 100644 src/hooks/useCommentManager.js diff --git a/mcp-server/src/server.js b/mcp-server/src/server.js index d975269..505d699 100644 --- a/mcp-server/src/server.js +++ b/mcp-server/src/server.js @@ -11,6 +11,7 @@ import { connectionTools, handleConnectionTool } from "./tools/connection-tools. import { documentTools, handleDocumentTool } from "./tools/document-tools.js"; import { modelTools, handleModelTool } from "./tools/model-tools.js"; import { annotationTools, handleAnnotationTool } from "./tools/annotation-tools.js"; +import { commentTools, handleCommentTool } from "./tools/comment-tools.js"; import { generationTools, handleGenerationTool } from "./tools/generation-tools.js"; const FILE_TOOL_NAMES = new Set(fileTools.map((t) => t.name)); @@ -20,6 +21,7 @@ const CONNECTION_TOOL_NAMES = new Set(connectionTools.map((t) => t.name)); const DOCUMENT_TOOL_NAMES = new Set(documentTools.map((t) => t.name)); const MODEL_TOOL_NAMES = new Set(modelTools.map((t) => t.name)); const ANNOTATION_TOOL_NAMES = new Set(annotationTools.map((t) => t.name)); +const COMMENT_TOOL_NAMES = new Set(commentTools.map((t) => t.name)); const GENERATION_TOOL_NAMES = new Set(generationTools.map((t) => t.name)); // filePath is injected into every non-file tool so callers can establish @@ -51,6 +53,7 @@ const ALL_TOOLS = [ ...withFilePath(documentTools), ...withFilePath(modelTools), ...withFilePath(annotationTools), + ...withFilePath(commentTools), ...withFilePath(generationTools), ]; @@ -90,6 +93,8 @@ export function createServer(state, renderer) { result = handleModelTool(name, args, state); } else if (ANNOTATION_TOOL_NAMES.has(name)) { result = handleAnnotationTool(name, args, state); + } else if (COMMENT_TOOL_NAMES.has(name)) { + result = handleCommentTool(name, args, state); } else if (GENERATION_TOOL_NAMES.has(name)) { result = handleGenerationTool(name, args, state); } else { diff --git a/mcp-server/src/state.js b/mcp-server/src/state.js index 85553e0..abb1da7 100644 --- a/mcp-server/src/state.js +++ b/mcp-server/src/state.js @@ -18,6 +18,7 @@ export class FlowState { this.dataModels = []; this.stickyNotes = []; this.screenGroups = []; + this.comments = []; this.metadata = { name: DEFAULT_FLOW_NAME, featureBrief: "", @@ -41,6 +42,7 @@ export class FlowState { this.dataModels = data.dataModels || []; this.stickyNotes = data.stickyNotes || []; this.screenGroups = data.screenGroups || []; + this.comments = data.comments || []; this.metadata = data.metadata || {}; this.viewport = data.viewport || { pan: { x: 0, y: 0 }, zoom: 1 }; this.filePath = filePath; @@ -65,6 +67,7 @@ export class FlowState { this.dataModels, this.stickyNotes, this.screenGroups, + this.comments, ); const dir = path.dirname(target); @@ -92,6 +95,7 @@ export class FlowState { this.dataModels = []; this.stickyNotes = []; this.screenGroups = []; + this.comments = []; this.metadata = { name: options.name || DEFAULT_FLOW_NAME, featureBrief: options.featureBrief || "", @@ -117,6 +121,8 @@ export class FlowState { dataModelCount: this.dataModels.length, stickyNoteCount: this.stickyNotes.length, screenGroupCount: this.screenGroups.length, + commentCount: this.comments.length, + unresolvedCommentCount: this.comments.filter((c) => !c.resolved).length, screens: this.screens.map((s) => ({ id: s.id, name: s.name, @@ -537,6 +543,78 @@ export class FlowState { this._autoSave(); } + // ── Comment Operations ────────────────────── + + addComment(options) { + const now = new Date().toISOString(); + const comment = { + id: generateId(), + text: (options.text || "").trim(), + authorName: options.authorName || "MCP Agent", + authorPeerId: null, + authorColor: options.authorColor || "#61afef", + targetType: options.targetType || "screen", + targetId: options.targetId || null, + screenId: options.screenId || options.targetId || null, + anchor: options.anchor || { xPct: 50, yPct: 50 }, + resolved: false, + resolvedAt: null, + resolvedBy: null, + createdAt: now, + updatedAt: now, + }; + this.comments.push(comment); + this._autoSave(); + return comment; + } + + updateComment(commentId, text) { + const comment = this.comments.find((c) => c.id === commentId); + if (!comment) throw new Error(`Comment not found: ${commentId}`); + comment.text = text.trim(); + comment.updatedAt = new Date().toISOString(); + this._autoSave(); + return comment; + } + + resolveComment(commentId, resolvedBy = "MCP Agent") { + const comment = this.comments.find((c) => c.id === commentId); + if (!comment) throw new Error(`Comment not found: ${commentId}`); + const now = new Date().toISOString(); + comment.resolved = true; + comment.resolvedAt = now; + comment.resolvedBy = resolvedBy; + comment.updatedAt = now; + this._autoSave(); + return comment; + } + + unresolveComment(commentId) { + const comment = this.comments.find((c) => c.id === commentId); + if (!comment) throw new Error(`Comment not found: ${commentId}`); + comment.resolved = false; + comment.resolvedAt = null; + comment.resolvedBy = null; + comment.updatedAt = new Date().toISOString(); + this._autoSave(); + return comment; + } + + deleteComment(commentId) { + const idx = this.comments.findIndex((c) => c.id === commentId); + if (idx === -1) throw new Error(`Comment not found: ${commentId}`); + this.comments.splice(idx, 1); + this._autoSave(); + } + + listComments(options = {}) { + let result = [...this.comments]; + if (options.targetId) result = result.filter((c) => c.targetId === options.targetId); + if (options.targetType) result = result.filter((c) => c.targetType === options.targetType); + if (options.resolved !== undefined) result = result.filter((c) => c.resolved === options.resolved); + return result; + } + // ── Metadata Operations ───────────────────── updateMetadata(updates) { diff --git a/mcp-server/src/tools/comment-tools.js b/mcp-server/src/tools/comment-tools.js new file mode 100644 index 0000000..dc6840e --- /dev/null +++ b/mcp-server/src/tools/comment-tools.js @@ -0,0 +1,112 @@ +export const commentTools = [ + { + name: "list_comments", + description: "List comments in the flow. Can filter by target, type, or resolved status.", + inputSchema: { + type: "object", + properties: { + targetId: { type: "string", description: "Filter by screenId, hotspotId, or connectionId" }, + targetType: { type: "string", enum: ["screen", "hotspot", "connection"], description: "Filter by target type" }, + resolved: { type: "boolean", description: "Filter by resolved status (omit for all)" }, + }, + }, + }, + { + name: "create_comment", + description: "Add a comment anchored to a screen, hotspot, or connection.", + inputSchema: { + type: "object", + properties: { + text: { type: "string", description: "Comment text" }, + targetType: { type: "string", enum: ["screen", "hotspot", "connection"], description: "What the comment is anchored to" }, + targetId: { type: "string", description: "ID of the target (screenId, hotspotId, or connectionId)" }, + screenId: { type: "string", description: "Parent screenId (required for hotspot targets; same as targetId for screen targets)" }, + authorName: { type: "string", description: "Display name for the comment author (default: 'MCP Agent')" }, + anchorXPct: { type: "number", description: "Horizontal anchor position as % of target width (0–100, default 50)" }, + anchorYPct: { type: "number", description: "Vertical anchor position as % of target height (0–100, default 50)" }, + }, + required: ["text", "targetType", "targetId"], + }, + }, + { + name: "update_comment", + description: "Edit the text of an existing comment.", + inputSchema: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to update" }, + text: { type: "string", description: "New comment text" }, + }, + required: ["commentId", "text"], + }, + }, + { + name: "resolve_comment", + description: "Mark a comment as resolved.", + inputSchema: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to resolve" }, + resolvedBy: { type: "string", description: "Name of the resolver (default: 'MCP Agent')" }, + }, + required: ["commentId"], + }, + }, + { + name: "delete_comment", + description: "Permanently delete a comment.", + inputSchema: { + type: "object", + properties: { + commentId: { type: "string", description: "Comment ID to delete" }, + }, + required: ["commentId"], + }, + }, +]; + +export function handleCommentTool(name, args, state) { + switch (name) { + case "list_comments": { + const comments = state.listComments({ + targetId: args.targetId, + targetType: args.targetType, + resolved: args.resolved, + }); + return { comments, count: comments.length }; + } + + case "create_comment": { + const comment = state.addComment({ + text: args.text, + targetType: args.targetType, + targetId: args.targetId, + screenId: args.screenId || (args.targetType === "screen" ? args.targetId : undefined), + authorName: args.authorName || "MCP Agent", + anchor: { + xPct: args.anchorXPct ?? 50, + yPct: args.anchorYPct ?? 50, + }, + }); + return { commentId: comment.id, authorName: comment.authorName, targetType: comment.targetType }; + } + + case "update_comment": { + const comment = state.updateComment(args.commentId, args.text); + return { success: true, commentId: comment.id }; + } + + case "resolve_comment": { + const comment = state.resolveComment(args.commentId, args.resolvedBy || "MCP Agent"); + return { success: true, commentId: comment.id, resolvedBy: comment.resolvedBy }; + } + + case "delete_comment": { + state.deleteComment(args.commentId); + return { success: true }; + } + + default: + throw new Error(`Unknown comment tool: ${name}`); + } +} diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 2a94a76..c0d27bb 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -18,6 +18,7 @@ import { useInstructionGeneration } from "./hooks/useInstructionGeneration"; import { useFileActions } from "./hooks/useFileActions"; import { diffPayload } from "./utils/diffPayload"; import { useCollabSync } from "./hooks/useCollabSync"; +import { useCommentManager } from "./hooks/useCommentManager"; import { useInteractionCallbacks } from "./hooks/useInteractionCallbacks"; import { useDerivedCanvasState } from "./hooks/useDerivedCanvasState"; import { useTemplateInserter } from "./hooks/useTemplateInserter"; @@ -45,6 +46,19 @@ export default function Drawd({ initialRoomCode }) { isSpaceHeld, spaceHeld, handleDragStart, handleMultiDragStart, handleMouseMove, handleMouseUp, handleCanvasMouseDown, } = useCanvas(activeTool); + // ── Comments (before useScreenManager so cleanup callbacks are stable) ──── + const { + comments, setComments, + addComment, updateComment, resolveComment, unresolveComment, deleteComment, + deleteCommentsForScreen, deleteCommentsForScreens, + deleteCommentsForHotspot, deleteCommentsForHotspots, + deleteCommentsForConnection, deleteCommentsForConnections, + } = useCommentManager(); + const [showComments, setShowComments] = useState(false); + const [selectedCommentId, setSelectedCommentId] = useState(null); + // commentComposer: { targetType, targetId, screenId, anchor, clientX, clientY } | null + const [commentComposer, setCommentComposer] = useState(null); + const { screens, connections, documents, selectedScreen, setSelectedScreen, fileInputRef, addScreen, addScreenAtCenter, removeScreen, removeScreens, renameScreen, moveScreen, moveScreens, @@ -58,7 +72,14 @@ export default function Drawd({ initialRoomCode }) { pushHistory, canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot, updateScreenStatus, markAllExisting, updateWireframe, - } = useScreenManager(pan, zoom, canvasRef); + } = useScreenManager(pan, zoom, canvasRef, { + onDeleteCommentsForScreen: deleteCommentsForScreen, + onDeleteCommentsForScreens: deleteCommentsForScreens, + onDeleteCommentsForHotspot: deleteCommentsForHotspot, + onDeleteCommentsForHotspots: deleteCommentsForHotspots, + onDeleteCommentsForConnection: deleteCommentsForConnection, + onDeleteCommentsForConnections: deleteCommentsForConnections, + }); // ── Canvas multi-object selection ──────────────────────────────────────── const { @@ -101,15 +122,16 @@ export default function Drawd({ initialRoomCode }) { const { collab, isReadOnly, + canEditFlow, canComment, canModerateComments, showShareModal, setShowShareModal, showParticipants, setShowParticipants, pendingRemoteStateRef, applyPendingRemoteState, } = useCollabSync({ screens, connections, documents, featureBrief, taskLink, techStack, - dataModels, stickyNotes, screenGroups, + dataModels, stickyNotes, screenGroups, comments, replaceAll, setFeatureBrief, setTaskLink, setTechStack, - setDataModels, setStickyNotes, setScreenGroups, + setDataModels, setStickyNotes, setScreenGroups, setComments, draggingRef, hotspotInteractionRef, patchScreenImage, canvasRef, pan, zoom, initialRoomCode, }); @@ -143,7 +165,7 @@ export default function Drawd({ initialRoomCode }) { const { connectedFileName, saveStatus, isFileSystemSupported, openFile, saveAs, saveNow, connectHandle, disconnect, - } = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, onExternalChange); + } = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, comments, onExternalChange); // ── File actions ─────────────────────────────────────────────────── const { applyPayload, onOpen, onSaveAs, onNew } = useFileActions({ @@ -151,7 +173,7 @@ export default function Drawd({ initialRoomCode }) { replaceAll, pushHistory, setPan, setZoom, setFeatureBrief, setTaskLink, setTechStack, - setDataModels, setStickyNotes, setScreenGroups, + setDataModels, setStickyNotes, setScreenGroups, setComments, setScopeRoot, openFile, saveAs, disconnect, }); // Complete the ref bridge so the poller can call applyPayload. @@ -289,7 +311,7 @@ export default function Drawd({ initialRoomCode }) { // ── Import / export ──────────────────────────────────────────────────────────────── const { importConfirm, setImportConfirm, importFileRef, onExport, onImport, onImportFileChange, onImportReplace, onImportMerge } = - useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups }); + useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, comments, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups, setComments }); // ── Toast notification ───────────────────────────────────────────────────────────── const [toast, setToast] = useState(null); @@ -384,7 +406,7 @@ export default function Drawd({ initialRoomCode }) { hotspotInteraction, cancelHotspotInteraction, selectedConnection, setSelectedConnection, selectedHotspots, setSelectedHotspots, - canvasSelection, setCanvasSelection, clearSelection, removeScreens, deleteStickyNote, addScreenGroup, screens, + canvasSelection, setCanvasSelection, clearSelection, removeScreens, deleteStickyNote, addScreenGroup, screens, stickyNotes, scopeScreenIds, connections, deleteHotspot, deleteHotspots, deleteConnection, deleteConnectionGroup, selectedScreen, removeScreen, selectedStickyNote, setSelectedStickyNote, @@ -393,6 +415,7 @@ export default function Drawd({ initialRoomCode }) { setActiveTool, onTemplates, isReadOnly, + canComment, duplicateSelection, onAddWireframe: handleAddWireframe, }); @@ -471,6 +494,10 @@ export default function Drawd({ initialRoomCode }) { onToggleParticipants={() => setShowParticipants((v) => !v)} showParticipants={showParticipants} onTemplates={onTemplates} + canComment={canComment} + showComments={showComments} + onToggleComments={() => setShowComments((v) => !v)} + unresolvedCommentCount={comments.filter((c) => !c.resolved).length} />
@@ -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 ( +
+