From a662312634526d44ab8f08d44b6abbdf5f5a0b52 Mon Sep 17 00:00:00 2001 From: lexflow-agent Date: Wed, 16 Sep 2026 22:47:22 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(frontend):=20Sprint=203=20comment=20UX?= =?UTF-8?q?=20=E2=80=94=20click=20highlights,=20badge,=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire clickable comment marks to open the panel, show unresolved count on the toolbar, block overlapping anchors, handle orphaned comments with reattach, strip comment marks from templates, and allow annotating in read mode. Co-authored-by: Cursor --- frontend/src/i18n/locales/en/common.json | 11 +- frontend/src/i18n/locales/es/common.json | 11 +- frontend/src/lib/comment-store.ts | 9 ++ frontend/src/pages/EditorPage.tsx | 40 +++++- .../src/pages/editor/CommentsPanel.test.tsx | 133 ++++++++++++++++++ frontend/src/pages/editor/CommentsPanel.tsx | 77 +++++++--- .../src/pages/editor/EditorToolbar.test.tsx | 82 +++++++++-- frontend/src/pages/editor/EditorToolbar.tsx | 39 +++-- frontend/src/pages/editor/TemplatesDialog.tsx | 5 +- .../src/pages/editor/comment-utils.test.ts | 98 ++++++++++++- frontend/src/pages/editor/comment-utils.ts | 47 ++++++- .../pages/editor/extensions/CommentMark.ts | 9 +- 12 files changed, 503 insertions(+), 58 deletions(-) create mode 100644 frontend/src/pages/editor/CommentsPanel.test.tsx diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index e3cd2f17..d144b4d0 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -72,6 +72,7 @@ "aiDraft": "AI drafting assistant", "commentSelection": "Comment selection", "viewComments": "View comments", + "commentBadgeAria": "{{count}} open comments", "undo": "Undo", "redo": "Redo", "switchToEdit": "Switch to edit mode", @@ -141,7 +142,15 @@ "deleteAria": "Delete comment", "delete": "Delete", "empty": "No comments. Select text in the document and press Comment.", - "resolvedSection": "Resolved" + "resolvedSection": "Resolved", + "overlapBlockedTitle": "Cannot comment", + "overlapBlocked": "The selection overlaps another comment. Choose a different span.", + "orphanBanner": "Anchor lost", + "orphanLocateTitle": "Anchor lost", + "orphanLocateBlocked": "The annotated text is no longer in the document. Reattach or delete the comment.", + "reattach": "Reattach", + "reattachSelectTitle": "Select text", + "reattachSelectHint": "Select the span in the document you want to reattach this comment to." }, "aiDraft": { "panelAria": "Drafting assistant", diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json index 143c7ed8..c561bf46 100644 --- a/frontend/src/i18n/locales/es/common.json +++ b/frontend/src/i18n/locales/es/common.json @@ -72,6 +72,7 @@ "aiDraft": "Asistente de redacción IA", "commentSelection": "Comentar la selección", "viewComments": "Ver comentarios", + "commentBadgeAria": "{{count}} comentarios abiertos", "undo": "Deshacer", "redo": "Rehacer", "switchToEdit": "Cambiar a modo edición", @@ -141,7 +142,15 @@ "deleteAria": "Eliminar comentario", "delete": "Eliminar", "empty": "No hay comentarios. Selecciona texto en el documento y pulsa Comentar.", - "resolvedSection": "Resueltos" + "resolvedSection": "Resueltos", + "overlapBlockedTitle": "No se puede comentar", + "overlapBlocked": "La selección se solapa con otro comentario. Elige un fragmento distinto.", + "orphanBanner": "Anclaje perdido", + "orphanLocateTitle": "Anclaje perdido", + "orphanLocateBlocked": "El texto anotado ya no está en el documento. Usa Reanclar o elimina el comentario.", + "reattach": "Reanclar", + "reattachSelectTitle": "Selecciona texto", + "reattachSelectHint": "Selecciona en el documento el fragmento al que quieres reanclar este comentario." }, "aiDraft": { "panelAria": "Asistente de redacción", diff --git a/frontend/src/lib/comment-store.ts b/frontend/src/lib/comment-store.ts index 3c2f6638..9e42835b 100644 --- a/frontend/src/lib/comment-store.ts +++ b/frontend/src/lib/comment-store.ts @@ -37,6 +37,8 @@ interface CommentState { addComment(comment: Omit): void; /** Update a comment's note text. */ updateNote(id: string, note: string): void; + /** Update the quoted anchor text after reattaching. */ + updateQuote(id: string, quote: string): void; /** Flip the resolved flag. */ toggleResolved(id: string): void; /** Remove a comment entirely. */ @@ -63,6 +65,13 @@ export const useCommentStore = create()( return { comments: { ...state.comments, [id]: { ...existing, note } } }; }), + updateQuote: (id, quote) => + set((state) => { + const existing = state.comments[id]; + if (!existing) return state; + return { comments: { ...state.comments, [id]: { ...existing, quote } } }; + }), + toggleResolved: (id) => set((state) => { const existing = state.comments[id]; diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 002c5b77..10d51173 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -50,6 +50,7 @@ import { CommentMark } from '@/pages/editor/extensions/CommentMark'; import { AiGeneratedMark } from '@/pages/editor/extensions/AiGeneratedMark'; import { PendingInsertHighlight } from '@/pages/editor/extensions/PendingInsertHighlight'; import { useCommentStore } from '@/lib/comment-store'; +import { openCommentCount } from '@/pages/editor/comment-utils'; import { cn } from '@/lib/utils'; import { DocumentList, DocumentPicker } from '@/pages/editor/DocumentList'; @@ -156,6 +157,15 @@ function EditorDocumentSurface({ docId }: { docId: string }) { const [commentsOpen, setCommentsOpen] = useState(false); const [focusCommentId, setFocusCommentId] = useState(null); const addComment = useCommentStore((s) => s.addComment); + const commentBadgeCount = useCommentStore((s) => openCommentCount(s.comments, docId)); + + const handleCommentClickRef = useRef<(commentId: string) => void>(() => {}); + useEffect(() => { + handleCommentClickRef.current = (commentId: string) => { + setFocusCommentId(commentId); + setCommentsOpen(true); + }; + }); const [saveStatus, setSaveStatus] = useState('saved'); @@ -193,6 +203,16 @@ function EditorDocumentSurface({ docId }: { docId: string }) { content: initialDoc.content, editable: !isReadOnly, immediatelyRender: true, + editorProps: { + handleClickOn: (_view, _pos, _node, _nodePos, event) => { + const el = (event.target as HTMLElement).closest('[data-comment-id]'); + if (!el) return false; + const commentId = el.getAttribute('data-comment-id'); + if (!commentId) return false; + handleCommentClickRef.current(commentId); + return true; + }, + }, onUpdate: ({ editor: ed }) => { if (autosaveTimer.current !== null) { clearTimeout(autosaveTimer.current); @@ -296,11 +316,22 @@ function EditorDocumentSurface({ docId }: { docId: string }) { if (empty) return; const quote = editor.state.doc.textBetween(from, to, ' '); const commentId = crypto.randomUUID(); - editor.chain().focus().setComment({ commentId, resolved: false }).run(); + const wasReadOnly = isReadOnly; + if (wasReadOnly) editor.setEditable(true); + const ok = editor.chain().focus().setComment({ commentId, resolved: false }).run(); + if (wasReadOnly) editor.setEditable(false); + if (!ok) { + toast({ + tone: 'warning', + title: t('editor.comments.overlapBlockedTitle'), + message: t('editor.comments.overlapBlocked'), + }); + return; + } addComment({ id: commentId, docId, quote, note: '' }); setFocusCommentId(commentId); setCommentsOpen(true); - }, [editor, docId, addComment]); + }, [editor, docId, addComment, isReadOnly, t]); const handleEmergencyExport = useCallback(() => { if (!editor) return; @@ -381,6 +412,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) { setFocusCommentId(null); setCommentsOpen(true); }} + commentBadgeCount={commentBadgeCount} /> )} @@ -460,9 +492,9 @@ function EditorDocumentSurface({ docId }: { docId: string }) { '[&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none', '[&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0', // Inline comment highlight (#602): amber span; resolved → dotted underline only. - '[&_.ProseMirror_.lex-comment]:rounded-sm [&_.ProseMirror_.lex-comment]:bg-[hsl(var(--amber-500)/0.28)]', + '[&_.ProseMirror_.lex-comment]:cursor-pointer [&_.ProseMirror_.lex-comment]:rounded-sm [&_.ProseMirror_.lex-comment]:bg-[hsl(var(--amber-500)/0.28)]', '[&_.ProseMirror_.lex-comment]:box-decoration-clone [&_.ProseMirror_.lex-comment]:px-0.5', - '[&_.ProseMirror_.lex-comment--resolved]:bg-transparent [&_.ProseMirror_.lex-comment--resolved]:px-0', + '[&_.ProseMirror_.lex-comment--resolved]:cursor-pointer [&_.ProseMirror_.lex-comment--resolved]:bg-transparent [&_.ProseMirror_.lex-comment--resolved]:px-0', '[&_.ProseMirror_.lex-comment--resolved]:underline [&_.ProseMirror_.lex-comment--resolved]:decoration-dotted', '[&_.ProseMirror_.lex-comment--resolved]:decoration-amber-400/70', '[&_.ProseMirror_.lex-ai-generated]:rounded-sm [&_.ProseMirror_.lex-ai-generated]:bg-[hsl(var(--indigo-500)/0.18)]', diff --git a/frontend/src/pages/editor/CommentsPanel.test.tsx b/frontend/src/pages/editor/CommentsPanel.test.tsx new file mode 100644 index 00000000..7b7342c6 --- /dev/null +++ b/frontend/src/pages/editor/CommentsPanel.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '@tiptap/react'; +import i18n from '@/i18n'; +import en from '@/i18n/locales/en/common.json'; +import { useCommentStore } from '@/lib/comment-store'; +import { CommentsPanel } from './CommentsPanel'; + +const toast = vi.fn(); + +vi.mock('@/lib/toast', () => ({ + toast: (...args: unknown[]) => toast(...args), +})); + +const setCommentMock = vi.fn().mockReturnThis(); +const unsetCommentMock = vi.fn().mockReturnThis(); +const chainMock = { + focus: vi.fn().mockReturnThis(), + setTextSelection: vi.fn().mockReturnThis(), + scrollIntoView: vi.fn().mockReturnThis(), + setComment: setCommentMock, + unsetComment: unsetCommentMock, + run: vi.fn().mockReturnValue(true), +}; + +const editorState = { + selection: { from: 1, to: 5, empty: false }, + doc: { + textBetween: vi.fn(() => 'new quote'), + descendants: vi.fn(), + }, +}; + +const editor = { + chain: () => chainMock, + state: editorState, + isEditable: true, + setEditable: vi.fn(), +} as unknown as Editor; + +describe('CommentsPanel orphan state', () => { + beforeEach(async () => { + toast.mockReset(); + setCommentMock.mockClear(); + chainMock.run.mockReturnValue(true); + useCommentStore.setState({ comments: {} }); + i18n.addResourceBundle('en', 'common', en, true, true); + await i18n.changeLanguage('en'); + }); + + it('shows an orphan banner when the anchor mark is missing', () => { + useCommentStore.setState({ + comments: { + c1: { + id: 'c1', + docId: 'draft', + quote: 'lost text', + note: '', + createdAt: '2026-01-01T00:00:00.000Z', + resolved: false, + }, + }, + }); + + render(); + expect(screen.getByText('Anchor lost')).toBeInTheDocument(); + expect(screen.getByText('Reattach')).toBeInTheDocument(); + }); + + it('toasts when locating an orphaned comment', () => { + useCommentStore.setState({ + comments: { + c1: { + id: 'c1', + docId: 'draft', + quote: 'lost text', + note: '', + createdAt: '2026-01-01T00:00:00.000Z', + resolved: false, + }, + }, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Locate' })); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ + tone: 'warning', + message: 'The annotated text is no longer in the document. Reattach or delete the comment.', + }), + ); + }); + + it('resolves an orphaned comment in the store without a mark', () => { + useCommentStore.setState({ + comments: { + c1: { + id: 'c1', + docId: 'draft', + quote: 'lost text', + note: '', + createdAt: '2026-01-01T00:00:00.000Z', + resolved: false, + }, + }, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Resolve' })); + expect(setCommentMock).not.toHaveBeenCalled(); + expect(useCommentStore.getState().comments.c1?.resolved).toBe(true); + }); + + it('reattaches a comment onto the current selection', () => { + useCommentStore.setState({ + comments: { + c1: { + id: 'c1', + docId: 'draft', + quote: 'old quote', + note: 'note', + createdAt: '2026-01-01T00:00:00.000Z', + resolved: false, + }, + }, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Reattach' })); + expect(setCommentMock).toHaveBeenCalledWith({ commentId: 'c1', resolved: false }); + expect(useCommentStore.getState().comments.c1?.quote).toBe('new quote'); + }); +}); diff --git a/frontend/src/pages/editor/CommentsPanel.tsx b/frontend/src/pages/editor/CommentsPanel.tsx index 853ee53a..33816426 100644 --- a/frontend/src/pages/editor/CommentsPanel.tsx +++ b/frontend/src/pages/editor/CommentsPanel.tsx @@ -12,10 +12,11 @@ import { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { Editor } from '@tiptap/react'; -import { MessageSquare, X, MapPin, Check, RotateCcw, Trash2 } from 'lucide-react'; +import { MessageSquare, X, MapPin, Check, RotateCcw, Trash2, Link2 } from 'lucide-react'; import { Button } from '@/components/ui'; +import { toast } from '@/lib/toast'; import { useCommentStore, type DocComment } from '@/lib/comment-store'; -import { filterDocComments } from './comment-utils'; +import { filterDocComments, findCommentRangeInDoc } from './comment-utils'; interface CommentsPanelProps { editor: Editor; @@ -27,22 +28,12 @@ interface CommentsPanelProps { /** Find the document range covered by a comment mark, or null if gone. */ function findCommentRange(editor: Editor, commentId: string): { from: number; to: number } | null { - let from: number | null = null; - let to: number | null = null; - editor.state.doc.descendants((node, pos) => { - if (!node.isText) return; - const hasMark = node.marks.some((m) => m.type.name === 'comment' && m.attrs.commentId === commentId); - if (hasMark) { - if (from === null) from = pos; - to = pos + node.nodeSize; - } - }); - return from !== null && to !== null ? { from, to } : null; + return findCommentRangeInDoc(editor.state.doc, commentId); } export function CommentsPanel({ editor, docId, focusCommentId, onClose }: CommentsPanelProps) { const { t } = useTranslation(); - const { comments, updateNote, toggleResolved, deleteComment } = useCommentStore(); + const { comments, updateNote, updateQuote, toggleResolved, deleteComment } = useCommentStore(); const all = filterDocComments(comments, docId, true); const active = all.filter((c) => !c.resolved); const resolved = all.filter((c) => c.resolved); @@ -65,23 +56,65 @@ export function CommentsPanel({ editor, docId, focusCommentId, onClose }: Commen const locate = (id: string) => { const range = findCommentRange(editor, id); - if (range) editor.chain().setTextSelection(range).scrollIntoView().focus().run(); + if (range) { + editor.chain().setTextSelection(range).scrollIntoView().focus().run(); + return; + } + toast({ + tone: 'warning', + title: t('editor.comments.orphanLocateTitle'), + message: t('editor.comments.orphanLocateBlocked'), + }); }; const resolve = (c: DocComment) => { const range = findCommentRange(editor, c.id); - if (range) editor.chain().setTextSelection(range).setComment({ commentId: c.id, resolved: !c.resolved }).run(); + if (range) { + editor.chain().setTextSelection(range).setComment({ commentId: c.id, resolved: !c.resolved }).run(); + } toggleResolved(c.id); }; + const reattach = (c: DocComment) => { + const { from, to, empty } = editor.state.selection; + if (empty) { + toast({ + tone: 'warning', + title: t('editor.comments.reattachSelectTitle'), + message: t('editor.comments.reattachSelectHint'), + }); + return; + } + const wasReadOnly = !editor.isEditable; + if (wasReadOnly) editor.setEditable(true); + const ok = editor.chain().focus().setComment({ commentId: c.id, resolved: c.resolved }).run(); + if (wasReadOnly) editor.setEditable(false); + if (!ok) { + toast({ + tone: 'warning', + title: t('editor.comments.overlapBlockedTitle'), + message: t('editor.comments.overlapBlocked'), + }); + return; + } + updateQuote(c.id, editor.state.doc.textBetween(from, to, ' ')); + }; + const remove = (id: string) => { const range = findCommentRange(editor, id); if (range) editor.chain().setTextSelection(range).unsetComment().run(); deleteComment(id); }; - const renderCard = (c: DocComment) => ( + const renderCard = (c: DocComment) => { + const anchored = findCommentRange(editor, c.id) !== null; + return (
+ {!anchored && ( +

+ {t('editor.comments.orphanBanner')} +

+ )} + {!anchored && ( + + )}
- ); + ); + }; return (