import React, { useLayoutEffect, useRef, useState } from 'react'; import { isMac, isWindows, altKey } from '../../utils/platform'; import { type QuickLabel, DEFAULT_QUICK_LABELS, LABEL_COLOR_MAP, emojiFromFieldInput, getLabelColors, getQuickLabels, moveQuickLabel, quickLabelShortcutDigit, resetQuickLabels, saveQuickLabels, } from '../../utils/quickLabels'; /** Most labels the tab lets a person keep (only the first ten get a key). */ const MAX_QUICK_LABELS = 12; /** * One editable row. `key` is a session-only identity, never persisted: the * saved shape stays exactly `QuickLabel[]`. A label's `id` cannot serve here — * it is re-derived from the text on every keystroke and two labels with the * same text share it — and position cannot either, because moving a row must * not move the open tip editor, an emoji draft, or focus onto a neighbour * (the #829 bug class, #1736). */ interface QuickLabelRow { key: string; label: QuickLabel; } let rowKeySeq = 0; const nextRowKey = () => `quick-label-row-${++rowKeySeq}`; const toRows = (labels: readonly QuickLabel[]): QuickLabelRow[] => labels.map((label) => ({ key: nextRowKey(), label })); const shortcutHint = (digit: string) => `${altKey}${isMac ? '' : '+'}${digit}`; const shortcutWords = (digit: string) => `${isMac ? 'Option' : 'Alt'}+${digit}`; const labelName = (label: QuickLabel) => label.text.trim() || 'label'; const EMOJI_HINT = isMac ? 'Enter one emoji. Control-Command-Space opens the emoji picker.' : isWindows ? 'Enter one emoji. Windows+period opens the emoji picker.' : 'Enter one emoji.'; const ChevronIcon: React.FC<{ up: boolean }> = ({ up }) => ( ); /** Settings → Labels: the Quick Labels editor (plan review and annotate). */ export const QuickLabelsTab: React.FC = () => { const [rows, setRows] = useState(() => toRows(getQuickLabels())); const [editingTipKey, setEditingTipKey] = useState(null); const [editingTipValue, setEditingTipValue] = useState(''); /** Emoji fields whose current text is not (yet) one emoji, by row key. */ const [emojiDrafts, setEmojiDrafts] = useState>({}); const [announcement, setAnnouncement] = useState(''); const listRef = useRef(null); const pendingFocus = useRef<{ key: string; target: 'up' | 'down' | 'text' } | null>(null); const commit = (next: QuickLabelRow[]) => { setRows(next); saveQuickLabels(next.map((row) => row.label)); }; const updateLabel = (key: string, patch: Partial) => { commit(rows.map((row) => (row.key === key ? { ...row, label: { ...row.label, ...patch } } : row))); }; const setEmojiDraft = (key: string, value: string | null) => { setEmojiDrafts((drafts) => { if (value === null) { if (!(key in drafts)) return drafts; const { [key]: _dropped, ...rest } = drafts; return rest; } return { ...drafts, [key]: value }; }); }; const move = (index: number, delta: -1 | 1) => { const to = index + delta; if (to < 0 || to >= rows.length) return; const row = rows[index]; commit(moveQuickLabel(rows, index, to)); pendingFocus.current = { key: row.key, target: delta < 0 ? 'up' : 'down' }; const digit = quickLabelShortcutDigit(to); setAnnouncement( `Moved ${labelName(row.label)} to position ${to + 1} of ${rows.length}, ${digit ? shortcutWords(digit) : 'no shortcut key'}.`, ); }; // Moving a row re-orders DOM nodes, which can drop focus from the button // that was pressed; put it back on the same row's button so repeated // presses keep walking the same label. At either end the pressed button is // disabled, so focus goes to the other direction instead. useLayoutEffect(() => { const pending = pendingFocus.current; if (!pending) return; pendingFocus.current = null; const row = listRef.current?.querySelector(`[data-quick-label-row="${pending.key}"]`); if (!row) return; if (pending.target === 'text') { const input = row.querySelector('[data-quick-label-text]'); input?.focus(); input?.select(); return; } const other = pending.target === 'up' ? 'down' : 'up'; const wanted = row.querySelector(`[data-quick-label-move="${pending.target}"]`); const fallback = row.querySelector(`[data-quick-label-move="${other}"]`); (wanted && !wanted.disabled ? wanted : fallback)?.focus(); }, [rows]); const saveTip = (key: string) => { updateLabel(key, { tip: editingTipValue || undefined }); setEditingTipKey(null); }; return ( <>
Quick Labels
Preset annotations for one-click feedback
{rows.map((row, index) => { const { key, label } = row; const colors = getLabelColors(label.color); const hasTip = !!label.tip; const isEditingTip = editingTipKey === key; const digit = quickLabelShortcutDigit(index); const name = labelName(label); const emojiDraft = emojiDrafts[key]; const emojiInvalid = emojiDraft !== undefined; const emojiErrorId = `${key}-emoji-error`; return (
{/* Main row. On a narrow dialog the controls after the text field wrap onto a second line instead of being clipped by the row's overflow-hidden. */}
e.target.select()} onChange={(e) => { const picked = emojiFromFieldInput(e.target.value, label.emoji); if (!picked) { setEmojiDraft(key, e.target.value); return; } setEmojiDraft(key, null); if (picked !== label.emoji) updateLabel(key, { emoji: picked }); }} onBlur={() => setEmojiDraft(key, null)} onKeyDown={(e) => { if (e.key === 'Escape' && emojiInvalid) { e.preventDefault(); e.stopPropagation(); setEmojiDraft(key, null); } }} className="w-8 py-1 bg-background/80 rounded text-sm text-center leading-none focus:outline-none focus:ring-1 focus:ring-primary/50 flex-shrink-0" style={emojiInvalid ? { boxShadow: '0 0 0 1px var(--destructive)' } : undefined} /> { updateLabel(key, { text: e.target.value, id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''), }); }} className="flex-1 min-w-0 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" // The basis is what makes the controls wrap: a row that // cannot give the text this much room moves them below. style={{ flexBasis: '8rem' }} />
{/* Tip indicator button */} {/* Reorder: position decides which Alt/⌥ digit applies the label. */}
{emojiInvalid && (
{EMOJI_HINT}
)} {/* Tip editor — slides open below the row */} {isEditingTip && (
setEditingTipValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') saveTip(key); if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setEditingTipKey(null); } }} placeholder="AI instruction tip..." className="flex-1 px-2 py-1 bg-background/60 rounded text-[10px] text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none focus:ring-1 focus:ring-primary/50" autoFocus onFocus={(e) => { e.target.setSelectionRange(0, 0); e.target.scrollLeft = 0; }} />
)}
); })}
{announcement}
{rows.length < MAX_QUICK_LABELS && ( )}
Use {shortcutHint('1')} through {shortcutHint('0')} when the annotation toolbar is visible to apply a label instantly. The order of the list decides the key; use the arrows to move a label, and click its emoji to change it.
); };