Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion frontend/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/comment-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ interface CommentState {
addComment(comment: Omit<DocComment, 'createdAt' | 'resolved'>): 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. */
Expand All @@ -63,6 +65,13 @@ export const useCommentStore = create<CommentState>()(
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];
Expand Down
40 changes: 36 additions & 4 deletions frontend/src/pages/EditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -156,6 +157,15 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
const [commentsOpen, setCommentsOpen] = useState<boolean>(false);
const [focusCommentId, setFocusCommentId] = useState<string | null>(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<SaveStatus>('saved');

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -381,6 +412,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
setFocusCommentId(null);
setCommentsOpen(true);
}}
commentBadgeCount={commentBadgeCount}
/>
)}

Expand Down Expand Up @@ -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)]',
Expand Down
195 changes: 195 additions & 0 deletions frontend/src/pages/editor/CommentsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CommentsPanel editor={editor} docId="draft" onClose={vi.fn()} />);
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(<CommentsPanel editor={editor} docId="draft" onClose={vi.fn()} />);
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(<CommentsPanel editor={editor} docId="draft" onClose={vi.fn()} />);
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(<CommentsPanel editor={editor} docId="draft" onClose={vi.fn()} />);
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(<CommentsPanel editor={editor} docId="draft" onClose={vi.fn()} />);
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(<CommentsPanel editor={editor} docId="draft" onClose={vi.fn()} />);
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);
});
});
Loading
Loading