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:
@@ -0,0 +1,133 @@
|
||||
import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import type { Viewport } from 'pixi-viewport';
|
||||
import type { AnnotationStore } from '../stores/annotationStore';
|
||||
import type { SceneManager } from './SceneManager';
|
||||
|
||||
const PIN_RADIUS = 10;
|
||||
const PIN_COLOR_OPEN = 0xee4444;
|
||||
const PIN_COLOR_RESOLVED = 0x666666;
|
||||
|
||||
export class PinOverlay extends Container {
|
||||
private _pins = new Map<string, Graphics>();
|
||||
private _voteBadges: Graphics[] = [];
|
||||
private _viewport: Viewport;
|
||||
private _scene: SceneManager;
|
||||
private _store: AnnotationStore;
|
||||
|
||||
constructor(viewport: Viewport, scene: SceneManager, store: AnnotationStore) {
|
||||
super();
|
||||
this._viewport = viewport;
|
||||
this._scene = scene;
|
||||
this._store = store;
|
||||
}
|
||||
|
||||
/** Call on every viewport moved/zoomed event and on store change */
|
||||
refresh(showResolved = false) {
|
||||
const scale = 1 / this._viewport.scale.x; // scale-independent size
|
||||
|
||||
// Remove old pins + badges
|
||||
for (const gfx of this._pins.values()) gfx.destroy();
|
||||
for (const gfx of this._voteBadges) gfx.destroy();
|
||||
this._pins.clear();
|
||||
this._voteBadges = [];
|
||||
this.removeChildren();
|
||||
|
||||
// ── Thread pins ──
|
||||
for (const thread of this._store.threads.values()) {
|
||||
if (thread.status === 'resolved' && !showResolved) continue;
|
||||
|
||||
const item = this._scene.items.get(thread.object_id);
|
||||
if (!item || !item.displayObject) continue;
|
||||
|
||||
const bounds = item.displayObject.getBounds();
|
||||
let wx: number, wy: number;
|
||||
|
||||
if (thread.anchor_type === 'point' && thread.pin_x != null && thread.pin_y != null) {
|
||||
wx = bounds.x + thread.pin_x * bounds.width;
|
||||
wy = bounds.y + thread.pin_y * bounds.height;
|
||||
} else {
|
||||
// Object-level: top-right corner
|
||||
wx = bounds.x + bounds.width;
|
||||
wy = bounds.y;
|
||||
}
|
||||
|
||||
const gfx = new Graphics();
|
||||
const r = PIN_RADIUS * scale;
|
||||
const color = thread.status === 'open' ? PIN_COLOR_OPEN : PIN_COLOR_RESOLVED;
|
||||
|
||||
gfx.circle(0, 0, r);
|
||||
gfx.fill({ color, alpha: 0.9 });
|
||||
gfx.stroke({ color: 0xffffff, width: 1.5 * scale, alpha: 0.8 });
|
||||
gfx.position.set(wx, wy);
|
||||
|
||||
// Count label
|
||||
if (thread.comment_count > 1) {
|
||||
const label = new Text({
|
||||
text: String(thread.comment_count),
|
||||
style: new TextStyle({
|
||||
fontSize: 9 * scale,
|
||||
fill: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
}),
|
||||
});
|
||||
label.anchor.set(0.5);
|
||||
gfx.addChild(label);
|
||||
}
|
||||
|
||||
gfx.eventMode = 'static';
|
||||
gfx.cursor = 'pointer';
|
||||
(gfx as any)._threadId = thread.id;
|
||||
|
||||
this._pins.set(thread.id, gfx);
|
||||
this.addChild(gfx);
|
||||
}
|
||||
|
||||
// ── Vote badges ──
|
||||
for (const [objectId, voters] of this._store.votes) {
|
||||
if (voters.size === 0) continue;
|
||||
const item = this._scene.items.get(objectId);
|
||||
if (!item || !item.displayObject) continue;
|
||||
|
||||
const bounds = item.displayObject.getBounds();
|
||||
const wx = bounds.x + bounds.width;
|
||||
const wy = bounds.y + bounds.height;
|
||||
|
||||
const gfx = new Graphics();
|
||||
const pw = 24 * scale;
|
||||
const ph = 16 * scale;
|
||||
const pr = 4 * scale;
|
||||
gfx.roundRect(-pw / 2, -ph / 2, pw, ph, pr);
|
||||
gfx.fill({ color: 0x2a3a50, alpha: 0.9 });
|
||||
gfx.position.set(wx - 16 * scale, wy - 4 * scale);
|
||||
|
||||
const label = new Text({
|
||||
text: `${voters.size}`,
|
||||
style: new TextStyle({ fontSize: 9 * scale, fill: '#4a9eff', fontWeight: 'bold' }),
|
||||
});
|
||||
label.anchor.set(0.5);
|
||||
gfx.addChild(label);
|
||||
this.addChild(gfx);
|
||||
this._voteBadges.push(gfx);
|
||||
}
|
||||
}
|
||||
|
||||
getThreadIdAtPoint(worldX: number, worldY: number): string | null {
|
||||
for (const [threadId, gfx] of this._pins) {
|
||||
const dx = gfx.position.x - worldX;
|
||||
const dy = gfx.position.y - worldY;
|
||||
const hitRadius = PIN_RADIUS / this._viewport.scale.x;
|
||||
if (dx * dx + dy * dy <= hitRadius * hitRadius) {
|
||||
return threadId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
override destroy(options?: any) {
|
||||
for (const gfx of this._pins.values()) gfx.destroy();
|
||||
for (const gfx of this._voteBadges) gfx.destroy();
|
||||
this._pins.clear();
|
||||
this._voteBadges = [];
|
||||
super.destroy(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import React, { useState, useCallback, useSyncExternalStore } from 'react';
|
||||
import { AnnotationStore, Thread } from '../stores/annotationStore';
|
||||
|
||||
interface FeedbackPanelProps {
|
||||
annotationStore: AnnotationStore;
|
||||
selectedObjectId: string | null;
|
||||
userId: string;
|
||||
boardId: string;
|
||||
token: string;
|
||||
canvasObjects: Map<string, { id: string; name?: string; type: string }>;
|
||||
onJumpToObject?: (objectId: string) => void;
|
||||
}
|
||||
|
||||
export default function FeedbackPanel({
|
||||
annotationStore,
|
||||
selectedObjectId,
|
||||
userId,
|
||||
boardId,
|
||||
token,
|
||||
canvasObjects,
|
||||
onJumpToObject,
|
||||
}: FeedbackPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [expandedThreadId, setExpandedThreadId] = useState<string | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | 'unresolved' | 'mine'>('unresolved');
|
||||
const [newCommentText, setNewCommentText] = useState('');
|
||||
const [showOrphans, setShowOrphans] = useState(false);
|
||||
|
||||
// Subscribe to store changes
|
||||
const _version = useSyncExternalStore(
|
||||
(cb) => annotationStore.subscribe(cb),
|
||||
() => annotationStore.threads.size + annotationStore.votes.size,
|
||||
);
|
||||
|
||||
const allThreads = Array.from(annotationStore.threads.values());
|
||||
|
||||
// Apply filter
|
||||
let threads = allThreads;
|
||||
if (filter === 'unresolved') threads = threads.filter((t) => t.status === 'open');
|
||||
if (filter === 'mine') threads = threads.filter((t) => t.created_by === userId);
|
||||
|
||||
// If an object is selected, show only its threads
|
||||
if (selectedObjectId) {
|
||||
threads = threads.filter((t) => t.object_id === selectedObjectId);
|
||||
}
|
||||
|
||||
// Separate orphaned threads (object deleted from canvas)
|
||||
const orphanedThreads = threads.filter((t) => !canvasObjects.has(t.object_id));
|
||||
threads = threads.filter((t) => canvasObjects.has(t.object_id));
|
||||
|
||||
// Sort: newest activity first
|
||||
threads.sort((a, b) => (b.last_commented_at || b.created_at).localeCompare(a.last_commented_at || a.created_at));
|
||||
|
||||
// ── API helpers ──
|
||||
|
||||
const postReply = useCallback(async (threadId: string) => {
|
||||
if (!replyText.trim()) return;
|
||||
await fetch(`/api/boards/${boardId}/threads/${threadId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content: replyText.trim() }),
|
||||
});
|
||||
setReplyText('');
|
||||
}, [boardId, token, replyText]);
|
||||
|
||||
const resolveThread = useCallback(async (threadId: string, status: string) => {
|
||||
await fetch(`/api/boards/${boardId}/threads/${threadId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}, [boardId, token]);
|
||||
|
||||
const deleteComment = useCallback(async (threadId: string, commentId: string) => {
|
||||
await fetch(`/api/boards/${boardId}/threads/${threadId}/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}, [boardId, token]);
|
||||
|
||||
const toggleVote = useCallback(async (objectId: string) => {
|
||||
await fetch(`/api/boards/${boardId}/votes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ object_id: objectId }),
|
||||
});
|
||||
}, [boardId, token]);
|
||||
|
||||
const createThread = useCallback(async () => {
|
||||
if (!newCommentText.trim() || !selectedObjectId) return;
|
||||
await fetch(`/api/boards/${boardId}/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
object_id: selectedObjectId,
|
||||
anchor_type: 'object',
|
||||
content: newCommentText.trim(),
|
||||
}),
|
||||
});
|
||||
setNewCommentText('');
|
||||
}, [boardId, token, newCommentText, selectedObjectId]);
|
||||
|
||||
// ── Collapsed state ──
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '28px',
|
||||
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '8px',
|
||||
}}>
|
||||
<button onClick={() => setCollapsed(false)} style={{
|
||||
background: 'none', border: 'none', color: '#777', cursor: 'pointer', padding: '2px',
|
||||
}} title="Open feedback panel">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 2a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2v3l3-3h7a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Expanded thread view ──
|
||||
|
||||
const expandedThread = expandedThreadId ? annotationStore.threads.get(expandedThreadId) : null;
|
||||
|
||||
if (expandedThread) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '280px',
|
||||
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
|
||||
display: 'flex', flexDirection: 'column', userSelect: 'none',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #1a1a1a', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button onClick={() => setExpandedThreadId(null)} style={{
|
||||
background: 'none', border: 'none', color: '#777', cursor: 'pointer', fontSize: '12px',
|
||||
}}>Back</button>
|
||||
<span style={{ color: '#aaa', fontSize: '12px', flex: 1 }}>
|
||||
{expandedThread.status === 'resolved' ? 'Resolved' : 'Open'}
|
||||
</span>
|
||||
<button onClick={() => toggleVote(expandedThread.object_id)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: annotationStore.hasVoted(expandedThread.object_id, userId) ? '#4a9eff' : '#444',
|
||||
fontSize: '11px',
|
||||
}}>
|
||||
{annotationStore.getVoteCount(expandedThread.object_id) > 0
|
||||
? `+${annotationStore.getVoteCount(expandedThread.object_id)}`
|
||||
: '+'}
|
||||
</button>
|
||||
{onJumpToObject && (
|
||||
<button onClick={() => onJumpToObject(expandedThread.object_id)} style={{
|
||||
background: 'none', border: 'none', color: '#4a9eff', cursor: 'pointer', fontSize: '10px',
|
||||
}}>Jump</button>
|
||||
)}
|
||||
{expandedThread.status === 'open' ? (
|
||||
<button onClick={() => resolveThread(expandedThread.id, 'resolved')} style={{
|
||||
background: '#1a3a1a', border: 'none', borderRadius: '4px', color: '#4ade80',
|
||||
padding: '2px 8px', cursor: 'pointer', fontSize: '11px',
|
||||
}}>Resolve</button>
|
||||
) : (
|
||||
<button onClick={() => resolveThread(expandedThread.id, 'open')} style={{
|
||||
background: '#1a1a3a', border: 'none', borderRadius: '4px', color: '#77a',
|
||||
padding: '2px 8px', cursor: 'pointer', fontSize: '11px',
|
||||
}}>Reopen</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px' }}>
|
||||
{expandedThread.comments.map((c) => (
|
||||
<div key={c.id} style={{ marginBottom: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
||||
<span style={{ color: '#ddd', fontSize: '12px', fontWeight: 600 }}>{c.author_name}</span>
|
||||
<span style={{ color: '#555', fontSize: '10px' }}>
|
||||
{new Date(c.created_at).toLocaleString()}
|
||||
</span>
|
||||
{c.edited_at && <span style={{ color: '#555', fontSize: '10px' }}>(edited)</span>}
|
||||
</div>
|
||||
<div style={{ color: '#bbb', fontSize: '13px', lineHeight: '1.4' }}>{c.content}</div>
|
||||
{c.user_id === userId && (
|
||||
<button onClick={() => deleteComment(expandedThread.id, c.id)} style={{
|
||||
background: 'none', border: 'none', color: '#644', cursor: 'pointer', fontSize: '10px', marginTop: '2px',
|
||||
}}>Delete</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Reply input */}
|
||||
<div style={{ padding: '8px', borderTop: '1px solid #1a1a1a' }}>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Reply..."
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%', background: '#1a1a1a', border: '1px solid #333', borderRadius: '4px',
|
||||
color: '#ddd', padding: '6px', fontSize: '12px', resize: 'none', boxSizing: 'border-box',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
postReply(expandedThread.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button onClick={() => postReply(expandedThread.id)} style={{
|
||||
marginTop: '4px', background: '#2a3a50', border: 'none', borderRadius: '4px',
|
||||
color: '#4a9eff', padding: '4px 12px', cursor: 'pointer', fontSize: '12px',
|
||||
}}>Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Thread list view ──
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '280px',
|
||||
background: '#111', borderLeft: '1px solid #1a1a1a', zIndex: 100,
|
||||
display: 'flex', flexDirection: 'column', userSelect: 'none',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #1a1a1a', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#ddd', fontSize: '13px', fontWeight: 600 }}>
|
||||
Feedback
|
||||
{allThreads.filter((t) => t.status === 'open').length > 0 && (
|
||||
<span style={{ color: '#e44', fontSize: '11px', fontWeight: 400, marginLeft: '6px' }}>
|
||||
{allThreads.filter((t) => t.status === 'open').length} open
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button onClick={() => setCollapsed(true)} style={{
|
||||
background: 'none', border: 'none', color: '#555', cursor: 'pointer', fontSize: '14px',
|
||||
}}>x</button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div style={{ padding: '6px 8px', borderBottom: '1px solid #1a1a1a', display: 'flex', gap: '4px' }}>
|
||||
{(['unresolved', 'all', 'mine'] as const).map((f) => (
|
||||
<button key={f} onClick={() => setFilter(f)} style={{
|
||||
background: filter === f ? '#2a3a50' : 'transparent',
|
||||
border: 'none', borderRadius: '4px', color: filter === f ? '#4a9eff' : '#555',
|
||||
padding: '2px 8px', cursor: 'pointer', fontSize: '11px', textTransform: 'capitalize',
|
||||
}}>{f}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New comment input (when object selected) */}
|
||||
{selectedObjectId && (
|
||||
<div style={{ padding: '8px', borderBottom: '1px solid #1a1a1a' }}>
|
||||
<div style={{ color: '#666', fontSize: '10px', marginBottom: '4px' }}>
|
||||
Comment on: {canvasObjects.get(selectedObjectId)?.name || canvasObjects.get(selectedObjectId)?.type || selectedObjectId.slice(0, 8)}
|
||||
</div>
|
||||
<textarea
|
||||
value={newCommentText}
|
||||
onChange={(e) => setNewCommentText(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
rows={2}
|
||||
style={{
|
||||
width: '100%', background: '#1a1a1a', border: '1px solid #333', borderRadius: '4px',
|
||||
color: '#ddd', padding: '6px', fontSize: '12px', resize: 'none', boxSizing: 'border-box',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) createThread();
|
||||
}}
|
||||
/>
|
||||
<button onClick={createThread} disabled={!newCommentText.trim()} style={{
|
||||
marginTop: '4px', background: newCommentText.trim() ? '#2a3a50' : '#1a1a1a',
|
||||
border: 'none', borderRadius: '4px',
|
||||
color: newCommentText.trim() ? '#4a9eff' : '#444',
|
||||
padding: '4px 12px', cursor: newCommentText.trim() ? 'pointer' : 'default', fontSize: '12px',
|
||||
}}>Comment</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thread list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{threads.length === 0 && !selectedObjectId && (
|
||||
<div style={{ padding: '16px', color: '#444', fontSize: '12px', textAlign: 'center' }}>
|
||||
No threads yet
|
||||
</div>
|
||||
)}
|
||||
{threads.length === 0 && selectedObjectId && (
|
||||
<div style={{ padding: '16px', color: '#444', fontSize: '12px', textAlign: 'center' }}>
|
||||
No comments on this object
|
||||
</div>
|
||||
)}
|
||||
{threads.map((t) => {
|
||||
const obj = canvasObjects.get(t.object_id);
|
||||
const firstComment = t.comments[0];
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => setExpandedThreadId(t.id)}
|
||||
style={{
|
||||
padding: '8px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = '#1a1a1a'; }}
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
||||
<span style={{
|
||||
width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: t.status === 'open' ? '#e44' : '#666', flexShrink: 0,
|
||||
}} />
|
||||
<span style={{ color: '#aaa', fontSize: '11px', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{obj?.name || obj?.type || t.object_id.slice(0, 8)}
|
||||
</span>
|
||||
<span style={{ color: '#555', fontSize: '10px' }}>{t.comment_count}</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); toggleVote(t.object_id); }} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px',
|
||||
color: annotationStore.hasVoted(t.object_id, userId) ? '#4a9eff' : '#444',
|
||||
fontSize: '11px', flexShrink: 0,
|
||||
}}>
|
||||
{annotationStore.getVoteCount(t.object_id) > 0
|
||||
? `+${annotationStore.getVoteCount(t.object_id)}`
|
||||
: '+'}
|
||||
</button>
|
||||
</div>
|
||||
{firstComment && (
|
||||
<div style={{ color: '#777', fontSize: '12px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{firstComment.author_name}: {firstComment.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphaned threads (deleted objects) */}
|
||||
{orphanedThreads.length > 0 && (
|
||||
<div>
|
||||
<div onClick={() => setShowOrphans(!showOrphans)} style={{
|
||||
padding: '8px', cursor: 'pointer', color: '#555', fontSize: '11px',
|
||||
borderTop: '1px solid #1a1a1a',
|
||||
}}>
|
||||
{showOrphans ? 'v' : '>'} Deleted items ({orphanedThreads.length})
|
||||
</div>
|
||||
{showOrphans && orphanedThreads.map((t) => {
|
||||
const firstComment = t.comments[0];
|
||||
return (
|
||||
<div key={t.id} onClick={() => setExpandedThreadId(t.id)} style={{
|
||||
padding: '8px', borderBottom: '1px solid #1a1a1a', cursor: 'pointer', opacity: 0.5,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
||||
<span style={{ width: '8px', height: '8px', borderRadius: '50%', background: '#444', flexShrink: 0 }} />
|
||||
<span style={{ color: '#666', fontSize: '11px', flex: 1 }}>{t.object_id.slice(0, 8)}...</span>
|
||||
<span style={{ color: '#555', fontSize: '10px' }}>{t.comment_count}</span>
|
||||
</div>
|
||||
{firstComment && (
|
||||
<div style={{ color: '#555', fontSize: '12px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{firstComment.author_name}: {firstComment.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ interface ToolbarProps {
|
||||
onToggleHelp?: () => void;
|
||||
onMmImport?: () => void;
|
||||
onExport?: () => void;
|
||||
onToggleReview?: () => void;
|
||||
reviewMode?: boolean;
|
||||
boardName?: string;
|
||||
}
|
||||
|
||||
@@ -150,6 +152,8 @@ export default function Toolbar({
|
||||
onToggleHelp,
|
||||
onMmImport,
|
||||
onExport,
|
||||
onToggleReview,
|
||||
reviewMode,
|
||||
}: ToolbarProps) {
|
||||
const showStroke = activeTool === ToolType.PEN;
|
||||
const showFontSize = activeTool === ToolType.TEXT;
|
||||
@@ -309,6 +313,15 @@ export default function Toolbar({
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Review / Feedback */}
|
||||
{onToggleReview && (
|
||||
<ActionBtn onClick={onToggleReview} title="Review Mode (R)" active={reviewMode}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M2 2.5A1.5 1.5 0 013.5 1h7A1.5 1.5 0 0112 2.5v6A1.5 1.5 0 0110.5 10H6l-3 3v-3H3.5A1.5 1.5 0 012 8.5v-6z" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { InboxZone } from '../canvas/InboxZone';
|
||||
import { LaserPointer } from '../canvas/LaserPointer';
|
||||
import { VideoSprite } from '../canvas/sprites/VideoSprite';
|
||||
import { UploadManager } from '../stores/uploadManager';
|
||||
import { AnnotationStore } from '../stores/annotationStore';
|
||||
import { PinOverlay } from '../canvas/PinOverlay';
|
||||
// PresenceOverlay removed — remote selection highlighting was too heavy for minimal benefit
|
||||
import { connectSocket, disconnectSocket } from '../socket';
|
||||
|
||||
@@ -64,6 +66,9 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
const dropCleanupRef = useRef<(() => void) | null>(null);
|
||||
const pasteCleanupRef = useRef<(() => void) | null>(null);
|
||||
const laserCleanupRef = useRef<(() => void) | null>(null);
|
||||
const annotationStoreRef = useRef<AnnotationStore | null>(null);
|
||||
if (!annotationStoreRef.current) annotationStoreRef.current = new AnnotationStore();
|
||||
const pinOverlayRef = useRef<PinOverlay | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!boardData || !resolvedBoardId) return;
|
||||
@@ -298,6 +303,65 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
laser.removeRemote(uid);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Annotations: load threads + votes, wire socket events ──
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
fetch(`/api/boards/${resolvedBoardId}/threads`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.threads) annotationStoreRef.current?.loadThreads(data.threads);
|
||||
})
|
||||
.catch((err) => console.error('[annotations] load threads error:', err));
|
||||
|
||||
fetch(`/api/boards/${resolvedBoardId}/votes`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.votes) annotationStoreRef.current?.loadVotes(data.votes);
|
||||
})
|
||||
.catch((err) => console.error('[annotations] load votes error:', err));
|
||||
}
|
||||
|
||||
socket.on('thread:add', (data: any) => {
|
||||
annotationStoreRef.current?.onThreadAdd(data.thread, data.comment);
|
||||
});
|
||||
socket.on('thread:status', (data: any) => {
|
||||
annotationStoreRef.current?.onThreadStatus(data.threadId, data.status, data.resolvedBy, data.resolvedAt);
|
||||
});
|
||||
socket.on('thread:delete', (data: any) => {
|
||||
annotationStoreRef.current?.onThreadDelete(data.threadId);
|
||||
});
|
||||
socket.on('comment:add', (data: any) => {
|
||||
annotationStoreRef.current?.onCommentAdd(data.threadId, data.comment);
|
||||
});
|
||||
socket.on('comment:update', (data: any) => {
|
||||
annotationStoreRef.current?.onCommentUpdate(data.threadId, data.commentId, data.content, data.editedAt);
|
||||
});
|
||||
socket.on('comment:delete', (data: any) => {
|
||||
annotationStoreRef.current?.onCommentDelete(data.threadId, data.commentId);
|
||||
});
|
||||
socket.on('vote:toggle', (data: any) => {
|
||||
annotationStoreRef.current?.onVoteToggle(data.objectId, data.userId, data.active);
|
||||
});
|
||||
|
||||
// ── Pin overlay (hidden by default, shown in Review Mode) ──
|
||||
const pinOverlay = new PinOverlay(viewport, scene, annotationStoreRef.current!);
|
||||
pinOverlay.visible = false;
|
||||
viewport.addChild(pinOverlay);
|
||||
pinOverlayRef.current = pinOverlay;
|
||||
|
||||
// Refresh overlay on viewport move
|
||||
viewport.on('moved', () => {
|
||||
if (pinOverlay.visible) pinOverlay.refresh();
|
||||
});
|
||||
// Refresh on store change
|
||||
annotationStoreRef.current!.subscribe(() => {
|
||||
if (pinOverlay.visible) pinOverlay.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup drag/drop and paste
|
||||
@@ -329,7 +393,14 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
||||
laserCleanupRef.current = null;
|
||||
dropCleanupRef.current?.();
|
||||
pasteCleanupRef.current?.();
|
||||
if (pinOverlayRef.current) {
|
||||
pinOverlayRef.current.destroy();
|
||||
pinOverlayRef.current = null;
|
||||
}
|
||||
annotationStoreRef.current?.clear();
|
||||
disconnectSocket();
|
||||
};
|
||||
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds]);
|
||||
|
||||
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current };
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
export interface Thread {
|
||||
id: string;
|
||||
board_id: string;
|
||||
object_id: string;
|
||||
anchor_type: 'object' | 'point';
|
||||
pin_x: number | null;
|
||||
pin_y: number | null;
|
||||
status: 'open' | 'resolved' | 'archived';
|
||||
resolved_by: string | null;
|
||||
resolved_at: string | null;
|
||||
comment_count: number;
|
||||
last_commented_at: string | null;
|
||||
last_commented_by: string | null;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
thread_id: string;
|
||||
user_id: string;
|
||||
author_name: string;
|
||||
author_color: string | null;
|
||||
content: string;
|
||||
edited_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Vote {
|
||||
board_id: string;
|
||||
object_id: string;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
export class AnnotationStore {
|
||||
threads = new Map<string, Thread>();
|
||||
/** objectId → Set<userId> */
|
||||
votes = new Map<string, Set<string>>();
|
||||
|
||||
private _listeners = new Set<Listener>();
|
||||
|
||||
subscribe(fn: Listener): () => void {
|
||||
this._listeners.add(fn);
|
||||
return () => this._listeners.delete(fn);
|
||||
}
|
||||
|
||||
private _notify() {
|
||||
for (const fn of this._listeners) fn();
|
||||
}
|
||||
|
||||
// ── Bulk load ──
|
||||
|
||||
loadThreads(threads: Thread[]) {
|
||||
this.threads.clear();
|
||||
for (const t of threads) {
|
||||
this.threads.set(t.id, t);
|
||||
}
|
||||
this._notify();
|
||||
}
|
||||
|
||||
loadVotes(votes: Vote[]) {
|
||||
this.votes.clear();
|
||||
for (const v of votes) {
|
||||
if (!this.votes.has(v.object_id)) this.votes.set(v.object_id, new Set());
|
||||
this.votes.get(v.object_id)!.add(v.user_id);
|
||||
}
|
||||
this._notify();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.threads.clear();
|
||||
this.votes.clear();
|
||||
this._notify();
|
||||
}
|
||||
|
||||
// ── Socket event handlers ──
|
||||
|
||||
onThreadAdd(thread: Thread, comment: Comment) {
|
||||
thread.comments = [comment];
|
||||
this.threads.set(thread.id, thread);
|
||||
this._notify();
|
||||
}
|
||||
|
||||
onThreadStatus(threadId: string, status: string, resolvedBy: string | null, resolvedAt: string | null) {
|
||||
const t = this.threads.get(threadId);
|
||||
if (!t) return;
|
||||
t.status = status as Thread['status'];
|
||||
t.resolved_by = resolvedBy;
|
||||
t.resolved_at = resolvedAt;
|
||||
this._notify();
|
||||
}
|
||||
|
||||
onThreadDelete(threadId: string) {
|
||||
this.threads.delete(threadId);
|
||||
this._notify();
|
||||
}
|
||||
|
||||
onCommentAdd(threadId: string, comment: Comment) {
|
||||
const t = this.threads.get(threadId);
|
||||
if (!t) return;
|
||||
t.comments.push(comment);
|
||||
t.comment_count = t.comments.length;
|
||||
t.last_commented_at = comment.created_at;
|
||||
t.last_commented_by = comment.user_id;
|
||||
this._notify();
|
||||
}
|
||||
|
||||
onCommentUpdate(threadId: string, commentId: string, content: string, editedAt: string) {
|
||||
const t = this.threads.get(threadId);
|
||||
if (!t) return;
|
||||
const c = t.comments.find((c) => c.id === commentId);
|
||||
if (c) {
|
||||
c.content = content;
|
||||
c.edited_at = editedAt;
|
||||
}
|
||||
this._notify();
|
||||
}
|
||||
|
||||
onCommentDelete(threadId: string, commentId: string) {
|
||||
const t = this.threads.get(threadId);
|
||||
if (!t) return;
|
||||
t.comments = t.comments.filter((c) => c.id !== commentId);
|
||||
t.comment_count = t.comments.length;
|
||||
this._notify();
|
||||
}
|
||||
|
||||
onVoteToggle(objectId: string, userId: string, active: boolean) {
|
||||
if (!this.votes.has(objectId)) this.votes.set(objectId, new Set());
|
||||
const set = this.votes.get(objectId)!;
|
||||
if (active) set.add(userId); else set.delete(userId);
|
||||
if (set.size === 0) this.votes.delete(objectId);
|
||||
this._notify();
|
||||
}
|
||||
|
||||
// ── Convenience getters ──
|
||||
|
||||
getThreadsForObject(objectId: string): Thread[] {
|
||||
const result: Thread[] = [];
|
||||
for (const t of this.threads.values()) {
|
||||
if (t.object_id === objectId) result.push(t);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getVoteCount(objectId: string): number {
|
||||
return this.votes.get(objectId)?.size ?? 0;
|
||||
}
|
||||
|
||||
hasVoted(objectId: string, userId: string): boolean {
|
||||
return this.votes.get(objectId)?.has(userId) ?? false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user