feat(annotations): comments, threads, voting system with review mode

Backend:
- comment_threads + comments + object_votes tables with indexes
- Thread/comment CRUD endpoints with socket broadcast
- Vote toggle endpoint with socket broadcast

Frontend:
- AnnotationStore for reactive thread/comment/vote state
- FeedbackPanel with thread list, expanded view, replies, filtering
- Vote toggle buttons in panel
- PinOverlay for canvas pin markers + vote badges
- Review Mode toggle in toolbar (comment bubble icon)
- Jump-to-object from thread view
- Orphaned thread detection for deleted objects
- New comment creation from panel when object selected
- Socket event wiring for real-time sync
This commit is contained in:
Hiren Kangad
2026-03-10 21:04:38 +05:30
parent b6b615957b
commit 303124f518
10 changed files with 1365 additions and 2 deletions
+45 -2
View File
@@ -26,6 +26,7 @@ import MattermostImport from '../components/MattermostImport';
import Minimap from '../components/Minimap';
import UploadPanel from '../components/UploadPanel';
import ExportDialog from '../components/ExportDialog';
import FeedbackPanel from '../components/FeedbackPanel';
import { UploadManager } from '../stores/uploadManager';
import { InboxZone } from '../canvas/InboxZone';
import { getItemWorldBounds } from '../canvas/SceneManager';
@@ -90,6 +91,7 @@ export default function Editor({ isPublicView }: EditorProps) {
const [showHelp, setShowHelp] = useState(false);
const [showMmImport, setShowMmImport] = useState(false);
const [showExport, setShowExport] = useState(false);
const [reviewMode, setReviewMode] = useState(false);
const [focusMode, setFocusMode] = useState(false);
const [layerList, setLayerList] = useState<any[]>([]);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
@@ -153,13 +155,21 @@ export default function Editor({ isPublicView }: EditorProps) {
getViewport,
});
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox)
useCanvasSetup({
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
const { annotationStore, pinOverlay } = useCanvasSetup({
boardData, resolvedBoardId, user, isPublicView,
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
});
// Toggle pin overlay visibility with review mode
useEffect(() => {
if (pinOverlay) {
pinOverlay.visible = reviewMode;
if (reviewMode) pinOverlay.refresh();
}
}, [reviewMode, pinOverlay]);
// Tool activation
useEffect(() => {
const viewport = canvasRef.current?.getViewport();
@@ -490,6 +500,8 @@ export default function Editor({ isPublicView }: EditorProps) {
onToggleHelp={() => setShowHelp((v) => !v)}
onMmImport={() => setShowMmImport(true)}
onExport={() => setShowExport(true)}
onToggleReview={() => setReviewMode((v) => !v)}
reviewMode={reviewMode}
boardName={board?.name}
/>}
@@ -643,6 +655,37 @@ export default function Editor({ isPublicView }: EditorProps) {
/>
)}
{/* Feedback / Review panel */}
{reviewMode && annotationStore && user && resolvedBoardId && (
<FeedbackPanel
annotationStore={annotationStore}
selectedObjectId={selectedLayerIds.length === 1 ? selectedLayerIds[0] : null}
userId={user.id}
boardId={resolvedBoardId}
token={localStorage.getItem('token') || ''}
canvasObjects={(() => {
const scene = canvasRef.current?.getScene();
const map = new Map<string, { id: string; name?: string; type: string }>();
if (scene) {
for (const item of scene.items.values()) {
map.set(item.id, { id: item.id, name: item.data.name, type: item.data.type });
}
}
return map;
})()}
onJumpToObject={(objectId) => {
const scene = canvasRef.current?.getScene();
const vp = canvasRef.current?.getViewport();
if (!scene || !vp) return;
const item = scene.items.get(objectId);
if (!item) return;
const b = getItemWorldBounds(item);
vp.animate({ time: 300, position: { x: b.x + b.w / 2, y: b.y + b.h / 2 }, ease: 'easeOutQuad' });
selectionRef.current?.selectOnly(objectId);
}}
/>
)}
{/* Upload progress panel */}
<UploadPanel uploadManager={uploadManager} />