diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index e3cd2f1..d144b4d 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 143c7ed..c561bf4 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 3c2f663..9e42835 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 002c5b7..10d5117 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 0000000..18a686f --- /dev/null +++ b/frontend/src/pages/editor/CommentsPanel.test.tsx @@ -0,0 +1,195 @@ +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'; +import * as commentUtils from './comment-utils'; + +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.setTextSelection.mockClear(); + chainMock.run.mockReturnValue(true); + vi.spyOn(commentUtils, 'findCommentRangeInDoc').mockReturnValue(null); + 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'); + }); +}); + +describe('CommentsPanel anchored resolve/reopen', () => { + const anchoredRange = { from: 1, to: 5 }; + + beforeEach(async () => { + toast.mockReset(); + setCommentMock.mockClear(); + chainMock.setTextSelection.mockClear(); + chainMock.run.mockReturnValue(true); + vi.spyOn(commentUtils, 'findCommentRangeInDoc').mockReturnValue(anchoredRange); + useCommentStore.setState({ comments: {} }); + i18n.addResourceBundle('en', 'common', en, true, true); + await i18n.changeLanguage('en'); + }); + + it('updates the mark when resolving an anchored comment', () => { + useCommentStore.setState({ + comments: { + c1: { + id: 'c1', + docId: 'draft', + quote: 'anchored text', + note: '', + createdAt: '2026-01-01T00:00:00.000Z', + resolved: false, + }, + }, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Resolve' })); + + expect(chainMock.setTextSelection).toHaveBeenCalledWith(anchoredRange); + expect(setCommentMock).toHaveBeenCalledWith({ commentId: 'c1', resolved: true }); + expect(useCommentStore.getState().comments.c1?.resolved).toBe(true); + }); + + it('updates the mark when reopening an anchored comment', () => { + useCommentStore.setState({ + comments: { + c1: { + id: 'c1', + docId: 'draft', + quote: 'anchored text', + note: '', + createdAt: '2026-01-01T00:00:00.000Z', + resolved: true, + }, + }, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Reopen' })); + + expect(chainMock.setTextSelection).toHaveBeenCalledWith(anchoredRange); + expect(setCommentMock).toHaveBeenCalledWith({ commentId: 'c1', resolved: false }); + expect(useCommentStore.getState().comments.c1?.resolved).toBe(false); + }); +}); diff --git a/frontend/src/pages/editor/CommentsPanel.tsx b/frontend/src/pages/editor/CommentsPanel.tsx index 853ee53..3381642 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 (