feat: annotation UX overhaul, animated packing, toolbar redesign

- PinOverlay: zero-allocation rendering with in-place Graphics/Text updates,
  fix WebGL texture crash (addressModeU null) from orphaned Text objects
- Pins always visible on media, track during drag via updatePositions() fast path
- Replace eraser with Review mode in toolbar tool group (orange gradient)
- Add tool instruction hints bar below toolbar (context-sensitive per tool)
- Feedback panel: glassmorphism, improved ThreadDetail/CommentItem/ThreadListItem
- Timestamps: relative with "ago" suffix, full date on hover tooltip
- De-emphasize pin numbers, show status text in thread list
- Animated arrangement transitions (easeOutCubic, 320ms) for pack/grid/row/column
- Disable snap guides (user requested)
- Paste now selects newly created items
- Remove eraser keyboard shortcuts, remap tool shortcuts
- Orphaned thread cleanup: safe destroy check for deleted media pins
This commit is contained in:
Hiren Kangad
2026-03-11 01:53:56 +05:30
parent ed8424d9a1
commit fc2d9df741
16 changed files with 611 additions and 360 deletions
@@ -1,8 +1,8 @@
import React from 'react';
import { Comment } from '../../stores/annotationStore';
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
import { relativeTime } from '../../utils/relativeTime';
import { TEXT_PRIMARY, TEXT_MUTED, BORDER } from './feedbackStyles';
import { relativeTime, fullTimestamp } from '../../utils/relativeTime';
import { TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED, BORDER, HOVER_BG } from './feedbackStyles';
interface CommentItemProps {
comment: Comment;
@@ -11,13 +11,23 @@ interface CommentItemProps {
}
export default function CommentItem({ comment, isOwn, onDelete }: CommentItemProps) {
const [hovered, setHovered] = React.useState(false);
return (
<div style={{ padding: '10px 0', borderBottom: `1px solid ${BORDER}` }}>
<div
style={{
padding: '12px 0',
borderBottom: `1px solid ${BORDER}`,
transition: 'background 0.1s ease',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '6px' }}>
<span
style={{
width: '22px',
height: '22px',
width: '24px',
height: '24px',
borderRadius: '50%',
background: getAuthorColor(comment.user_id),
display: 'flex',
@@ -31,30 +41,31 @@ export default function CommentItem({ comment, isOwn, onDelete }: CommentItemPro
>
{getAuthorInitial(comment.author_name)}
</span>
<span style={{ color: TEXT_PRIMARY, fontSize: '12px', fontWeight: 600 }}>
<span style={{ color: TEXT_PRIMARY, fontSize: '12px', fontWeight: 600, letterSpacing: '0.2px' }}>
{comment.author_name}
</span>
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
<span style={{ color: TEXT_MUTED, fontSize: '10px' }} title={fullTimestamp(comment.created_at)}>
{relativeTime(comment.created_at)}
</span>
{comment.edited_at && (
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>(edited)</span>
<span style={{ color: TEXT_MUTED, fontSize: '10px', fontStyle: 'italic' }}>edited</span>
)}
<div style={{ flex: 1 }} />
{isOwn && onDelete && (
{isOwn && onDelete && hovered && (
<button
onClick={onDelete}
style={{
background: 'none',
border: 'none',
color: '#633',
background: 'rgba(240, 72, 72, 0.08)',
border: '1px solid rgba(240, 72, 72, 0.12)',
borderRadius: '4px',
color: TEXT_MUTED,
cursor: 'pointer',
fontSize: '10px',
opacity: 0.6,
transition: 'opacity 0.1s',
padding: '2px 6px',
transition: 'all 0.1s',
}}
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0.6')}
onMouseEnter={(e) => { e.currentTarget.style.color = '#f04848'; e.currentTarget.style.background = 'rgba(240, 72, 72, 0.15)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = TEXT_MUTED; e.currentTarget.style.background = 'rgba(240, 72, 72, 0.08)'; }}
>
Delete
</button>
@@ -62,10 +73,10 @@ export default function CommentItem({ comment, isOwn, onDelete }: CommentItemPro
</div>
<div
style={{
color: '#ccc',
color: TEXT_SECONDARY,
fontSize: '13px',
lineHeight: 1.5,
marginLeft: '30px',
marginLeft: '32px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
@@ -3,12 +3,13 @@ import { Thread, AnnotationStore } from '../../stores/annotationStore';
import CommentItem from './CommentItem';
import CommentInput from './CommentInput';
import {
PANEL_BG,
PANEL_WIDTH,
panelContainerStyle,
BORDER,
TEXT_PRIMARY,
TEXT_SECONDARY,
TEXT_MUTED,
ACCENT,
HOVER_BG,
STATUS_OPEN,
STATUS_RESOLVED,
} from './feedbackStyles';
@@ -58,25 +59,11 @@ export default function ThreadDetail({
};
return (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: `${PANEL_WIDTH}px`,
background: PANEL_BG,
borderLeft: `1px solid ${BORDER}`,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
userSelect: 'none',
}}
>
<div style={panelContainerStyle}>
{/* Header */}
<div
style={{
padding: '10px 14px',
padding: '14px 16px',
borderBottom: `1px solid ${BORDER}`,
display: 'flex',
alignItems: 'center',
@@ -90,13 +77,17 @@ export default function ThreadDetail({
border: 'none',
color: TEXT_MUTED,
cursor: 'pointer',
fontSize: '14px',
padding: '2px',
fontSize: '16px',
padding: '2px 4px',
borderRadius: '4px',
transition: 'color 0.1s',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = TEXT_PRIMARY)}
onMouseLeave={(e) => (e.currentTarget.style.color = TEXT_MUTED)}
>
&larr;
</button>
<span style={{ color: TEXT_MUTED, fontSize: '11px', fontFamily: 'monospace' }}>
<span style={{ color: TEXT_SECONDARY, fontSize: '11px', fontFamily: 'monospace', fontWeight: 500 }}>
#{pinNumber}
</span>
<span
@@ -105,8 +96,9 @@ export default function ThreadDetail({
fontWeight: 600,
padding: '2px 8px',
borderRadius: '10px',
background: isOpen ? '#2a1515' : '#152a15',
background: isOpen ? 'rgba(240, 72, 72, 0.1)' : 'rgba(52, 210, 123, 0.1)',
color: isOpen ? STATUS_OPEN : STATUS_RESOLVED,
letterSpacing: '0.3px',
}}
>
{isOpen ? 'Open' : 'Resolved'}
@@ -116,12 +108,17 @@ export default function ThreadDetail({
<button
onClick={() => onJumpToObject(thread.object_id)}
style={{
background: 'none',
border: 'none',
background: 'rgba(74, 158, 255, 0.08)',
border: `1px solid rgba(74, 158, 255, 0.15)`,
borderRadius: '6px',
color: ACCENT,
cursor: 'pointer',
fontSize: '11px',
padding: '4px 10px',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.15)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.08)')}
>
Jump
</button>
@@ -130,14 +127,18 @@ export default function ThreadDetail({
<button
onClick={() => onResolve(thread.id, 'resolved')}
style={{
background: '#152a15',
border: 'none',
background: 'rgba(52, 210, 123, 0.1)',
border: `1px solid rgba(52, 210, 123, 0.15)`,
borderRadius: '6px',
color: STATUS_RESOLVED,
padding: '4px 10px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(52, 210, 123, 0.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(52, 210, 123, 0.1)')}
>
Resolve
</button>
@@ -145,14 +146,18 @@ export default function ThreadDetail({
<button
onClick={() => onResolve(thread.id, 'open')}
style={{
background: '#1a1a2a',
border: 'none',
background: 'rgba(74, 158, 255, 0.08)',
border: `1px solid rgba(74, 158, 255, 0.15)`,
borderRadius: '6px',
color: '#88f',
color: ACCENT,
padding: '4px 10px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: 500,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.15)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(74, 158, 255, 0.08)')}
>
Reopen
</button>
@@ -194,11 +199,16 @@ export default function ThreadDetail({
style={{
background: 'none',
border: 'none',
color: '#633',
color: TEXT_MUTED,
cursor: 'pointer',
fontSize: '13px',
padding: '2px',
fontSize: '14px',
padding: '2px 4px',
borderRadius: '4px',
opacity: 0.6,
transition: 'all 0.1s',
}}
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = STATUS_OPEN; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.6'; e.currentTarget.style.color = TEXT_MUTED; }}
title="Delete thread"
>
&times;
@@ -207,7 +217,7 @@ export default function ThreadDetail({
</div>
{/* Comments */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 14px' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px', scrollbarWidth: 'thin', scrollbarColor: '#2a2a2e transparent' }}>
{thread.comments.map((c) => (
<CommentItem
key={c.id}
@@ -219,7 +229,7 @@ export default function ThreadDetail({
</div>
{/* Reply input */}
<div style={{ padding: '10px 14px', borderTop: `1px solid ${BORDER}` }}>
<div style={{ padding: '12px 16px', borderTop: `1px solid ${BORDER}` }}>
<CommentInput
value={replyText}
onChange={setReplyText}
+48 -51
View File
@@ -3,13 +3,14 @@ import { Thread, AnnotationStore } from '../../stores/annotationStore';
import ThreadListItem from './ThreadListItem';
import CommentInput from './CommentInput';
import {
PANEL_BG,
PANEL_WIDTH,
panelContainerStyle,
BORDER,
TEXT_PRIMARY,
TEXT_SECONDARY,
TEXT_MUTED,
STATUS_OPEN,
FILTER_ACTIVE_BG,
ACCENT,
} from './feedbackStyles';
export type FilterType = 'open' | 'resolved' | 'all' | 'mine';
@@ -23,7 +24,6 @@ interface ThreadListProps {
onFilterChange: (f: FilterType) => void;
onSelectThread: (id: string) => void;
onCollapse: () => void;
// New comment
selectedObjectId: string | null;
selectedObjectLabel: string;
newCommentText: string;
@@ -49,32 +49,21 @@ export default function ThreadList({
const [showOrphans, setShowOrphans] = React.useState(false);
return (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: `${PANEL_WIDTH}px`,
background: PANEL_BG,
borderLeft: `1px solid ${BORDER}`,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
userSelect: 'none',
}}
>
<div style={panelContainerStyle}>
{/* Header */}
<div
style={{
padding: '12px 14px',
padding: '14px 16px',
borderBottom: `1px solid ${BORDER}`,
display: 'flex',
alignItems: 'center',
gap: '8px',
gap: '10px',
}}
>
<span style={{ color: TEXT_PRIMARY, fontSize: '14px', fontWeight: 600, flex: 1 }}>
<svg width="16" height="16" viewBox="0 0 14 14" fill="none" stroke={TEXT_SECONDARY} strokeWidth="1.2" style={{ flexShrink: 0 }}>
<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>
<span style={{ color: TEXT_PRIMARY, fontSize: '13px', fontWeight: 600, flex: 1, letterSpacing: '0.3px' }}>
Feedback
</span>
{openCount > 0 && (
@@ -82,12 +71,13 @@ export default function ThreadList({
style={{
background: STATUS_OPEN,
color: '#fff',
fontSize: '11px',
fontWeight: 600,
padding: '1px 7px',
fontSize: '10px',
fontWeight: 700,
padding: '2px 7px',
borderRadius: '10px',
minWidth: '18px',
textAlign: 'center',
lineHeight: '14px',
}}
>
{openCount}
@@ -102,8 +92,12 @@ export default function ThreadList({
cursor: 'pointer',
fontSize: '16px',
lineHeight: 1,
padding: '2px',
padding: '2px 4px',
borderRadius: '4px',
transition: 'color 0.1s',
}}
onMouseEnter={(e) => (e.currentTarget.style.color = TEXT_PRIMARY)}
onMouseLeave={(e) => (e.currentTarget.style.color = TEXT_MUTED)}
>
&times;
</button>
@@ -112,10 +106,10 @@ export default function ThreadList({
{/* Filter bar */}
<div
style={{
padding: '8px 14px',
padding: '8px 16px',
borderBottom: `1px solid ${BORDER}`,
display: 'flex',
gap: '6px',
gap: '4px',
}}
>
{(['open', 'resolved', 'all', 'mine'] as const).map((f) => (
@@ -124,15 +118,16 @@ export default function ThreadList({
onClick={() => onFilterChange(f)}
style={{
background: filter === f ? FILTER_ACTIVE_BG : 'transparent',
border: 'none',
border: filter === f ? `1px solid ${BORDER}` : '1px solid transparent',
borderRadius: '6px',
color: filter === f ? '#fff' : TEXT_MUTED,
color: filter === f ? TEXT_PRIMARY : TEXT_MUTED,
padding: '4px 10px',
cursor: 'pointer',
fontSize: '11px',
fontWeight: filter === f ? 600 : 400,
textTransform: 'capitalize',
transition: 'all 0.1s ease',
transition: 'all 0.15s ease',
lineHeight: '16px',
}}
>
{f}
@@ -142,9 +137,12 @@ export default function ThreadList({
{/* New comment input (when object selected) */}
{selectedObjectId && (
<div style={{ padding: '10px 14px', borderBottom: `1px solid ${BORDER}` }}>
<div style={{ color: TEXT_MUTED, fontSize: '10px', marginBottom: '6px' }}>
Comment on: <span style={{ color: TEXT_PRIMARY }}>{selectedObjectLabel}</span>
<div style={{ padding: '12px 16px', borderBottom: `1px solid ${BORDER}`, background: 'rgba(74, 158, 255, 0.03)' }}>
<div style={{ color: TEXT_MUTED, fontSize: '10px', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Comment on
</div>
<div style={{ color: TEXT_PRIMARY, fontSize: '12px', marginBottom: '10px', fontWeight: 500 }}>
{selectedObjectLabel}
</div>
<CommentInput
value={newCommentText}
@@ -157,24 +155,23 @@ export default function ThreadList({
)}
{/* Thread list */}
<div style={{ flex: 1, overflowY: 'auto' }}>
<div style={{ flex: 1, overflowY: 'auto', scrollbarWidth: 'thin', scrollbarColor: '#2a2a2e transparent' }}>
{threads.length === 0 && (
<div style={{ padding: '32px 20px', textAlign: 'center' }}>
<svg
width="32"
height="32"
viewBox="0 0 14 14"
fill="none"
stroke="#333"
strokeWidth="1"
style={{ marginBottom: '12px' }}
>
<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>
<div style={{ color: TEXT_MUTED, fontSize: '12px', lineHeight: 1.5 }}>
<div style={{ padding: '40px 24px', textAlign: 'center' }}>
<div style={{
width: '48px', height: '48px', borderRadius: '50%',
background: 'rgba(74, 158, 255, 0.06)', border: `1px solid rgba(74, 158, 255, 0.1)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 16px',
}}>
<svg width="20" height="20" viewBox="0 0 14 14" fill="none" stroke={TEXT_MUTED} strokeWidth="1">
<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>
</div>
<div style={{ color: TEXT_MUTED, fontSize: '12px', lineHeight: 1.6 }}>
{selectedObjectId
? 'No comments on this item.'
: 'No comments yet.\nSelect an image and add a comment.'}
: <>No comments yet.<br /><span style={{ color: TEXT_SECONDARY }}>Select an image to leave feedback.</span></>}
</div>
</div>
)}
@@ -193,14 +190,14 @@ export default function ThreadList({
<div
onClick={() => setShowOrphans(!showOrphans)}
style={{
padding: '10px 14px',
padding: '10px 16px',
cursor: 'pointer',
color: TEXT_MUTED,
fontSize: '11px',
borderTop: `1px solid ${BORDER}`,
transition: 'background 0.1s ease',
transition: 'background 0.15s ease',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#151515')}
onMouseEnter={(e) => (e.currentTarget.style.background = '#16161a')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{showOrphans ? '\u25BE' : '\u25B8'} Deleted items ({orphanedThreads.length})
@@ -1,7 +1,7 @@
import React from 'react';
import { Thread } from '../../stores/annotationStore';
import { getAuthorColor, getAuthorInitial } from '../../utils/authorColors';
import { relativeTime } from '../../utils/relativeTime';
import { relativeTime, fullTimestamp } from '../../utils/relativeTime';
import {
TEXT_PRIMARY,
TEXT_SECONDARY,
@@ -26,7 +26,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
<div
onClick={onClick}
style={{
padding: '10px 14px',
padding: '12px 16px',
borderBottom: `1px solid ${BORDER}`,
cursor: 'pointer',
transition: 'background 0.1s ease',
@@ -67,7 +67,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
>
{firstComment?.author_name || 'Unknown'}
</span>
<span style={{ color: TEXT_MUTED, fontSize: '10px', flexShrink: 0 }}>
<span style={{ color: TEXT_MUTED, fontSize: '10px', flexShrink: 0 }} title={fullTimestamp(thread.last_commented_at || thread.created_at)}>
{relativeTime(thread.last_commented_at || thread.created_at)}
</span>
</div>
@@ -89,7 +89,7 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
</div>
)}
{/* Row 3: meta — pin #, replies, status dot */}
{/* Row 3: meta — replies, status */}
<div
style={{
display: 'flex',
@@ -99,9 +99,6 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
marginLeft: '32px',
}}
>
<span style={{ color: TEXT_MUTED, fontSize: '10px', fontFamily: 'monospace' }}>
#{pinNumber}
</span>
{thread.comment_count > 1 && (
<span style={{ color: TEXT_MUTED, fontSize: '10px' }}>
{thread.comment_count - 1} {thread.comment_count === 2 ? 'reply' : 'replies'}
@@ -109,13 +106,15 @@ export default function ThreadListItem({ thread, pinNumber, onClick }: ThreadLis
)}
<span
style={{
width: '6px',
height: '6px',
borderRadius: '50%',
background: isResolved ? STATUS_RESOLVED : STATUS_OPEN,
fontSize: '10px',
fontWeight: 500,
color: isResolved ? STATUS_RESOLVED : STATUS_OPEN,
marginLeft: 'auto',
textTransform: 'capitalize',
}}
/>
>
{thread.status}
</span>
</div>
</div>
);
@@ -1,18 +1,40 @@
// Design tokens for the feedback/annotation system
export const PANEL_WIDTH = 300;
export const PANEL_BG = '#0d0d0d';
export const BORDER = '#1e1e1e';
export const PANEL_WIDTH = 320;
export const PANEL_BG = 'rgba(12, 12, 14, 0.95)';
export const PANEL_BG_SOLID = '#0c0c0e';
export const BORDER = '#1a1a1e';
export const TEXT_PRIMARY = '#e0e0e0';
export const TEXT_SECONDARY = '#999';
export const TEXT_MUTED = '#666';
export const TEXT_PRIMARY = '#eaeaea';
export const TEXT_SECONDARY = '#a0a0a8';
export const TEXT_MUTED = '#5a5a64';
export const ACCENT = '#4a9eff';
export const STATUS_OPEN = '#ef4444';
export const STATUS_RESOLVED = '#22c55e';
export const STATUS_OPEN = '#f04848';
export const STATUS_RESOLVED = '#34d27b';
export const INPUT_BG = '#141414';
export const INPUT_BORDER = '#2a2a2a';
export const INPUT_BORDER_FOCUS = '#3a3a3a';
export const HOVER_BG = '#151515';
export const FILTER_ACTIVE_BG = '#2a2a2a';
export const INPUT_BG = '#131316';
export const INPUT_BORDER = '#26262c';
export const INPUT_BORDER_FOCUS = '#4a9eff44';
export const HOVER_BG = '#16161a';
export const FILTER_ACTIVE_BG = '#1e1e24';
// Shared panel container style
export const panelContainerStyle: React.CSSProperties = {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: `${PANEL_WIDTH}px`,
background: PANEL_BG,
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
borderLeft: `1px solid ${BORDER}`,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
userSelect: 'none',
fontFamily: "'Inter', system-ui, -apple-system, sans-serif",
};
// Import this in components that need React.CSSProperties type
import type React from 'react';