cc-haha/desktop/src/components/chat/AttachmentGallery.tsx
程序员阿江(Relakkes) 14c37d5fc1 fix: polish preview selection affordances
Preview selection turns should disclose the user's requested change on hover
or focus, while the browser capture controls should live in the address
toolbar like the referenced Codex UI. The message keeps a compact element tag
by default and exposes the note through a real tooltip instead of relying on a
native title-only hint.

Constraint: No issue number was provided for this task.
Constraint: Preserve the existing preview bridge protocol and direct-send selection flow.
Rejected: Native title-only hover text | it is too subtle and does not match the requested visible hover affordance.
Rejected: Keep a second preview action toolbar row | it contradicts the referenced right-aligned browser chrome.
Confidence: high
Scope-risk: narrow
Directive: Keep preview capture and picker actions in the browser address chrome unless the reference interaction changes.
Tested: cd desktop && bun run test --run src/components/chat/AttachmentGallery.test.tsx src/components/browser/BrowserSurface.test.tsx src/components/browser/BrowserAddressBar.test.tsx
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: git diff --check
Not-tested: Real Tauri webview visual smoke; browser automation startup failed, local Vite HTTP smoke returned 200.
2026-06-01 01:41:54 +08:00

189 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react'
import { ImageGalleryModal } from './ImageGalleryModal'
export type AttachmentPreview = {
id?: string
type: 'image' | 'file'
name: string
path?: string
data?: string
previewUrl?: string
isDirectory?: boolean
lineStart?: number
lineEnd?: number
note?: string
quote?: string
}
type Props = {
attachments: AttachmentPreview[]
variant?: 'composer' | 'message'
onRemove?: (id: string) => void
}
export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) {
const [activeImageIndex, setActiveImageIndex] = useState<number | null>(null)
const images = useMemo(
() =>
attachments
.filter((attachment) => attachment.type === 'image' && (attachment.previewUrl || attachment.data))
.map((attachment) => ({
src: attachment.previewUrl || attachment.data || '',
name: attachment.name,
})),
[attachments],
)
if (attachments.length === 0) return null
const isComposer = variant === 'composer'
return (
<>
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
{attachments.map((attachment, index) => {
if (attachment.type === 'image' && (attachment.previewUrl || attachment.data)) {
const src = attachment.previewUrl || attachment.data || ''
const selectionNote = attachment.note?.trim()
const hasSelectionNote = !isComposer && !!selectionNote
const tooltipId = hasSelectionNote
? `selection-note-${(attachment.id || `${attachment.name}-${index}`).replace(/[^a-zA-Z0-9_-]/g, '-')}`
: undefined
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
className={isComposer ? 'group relative' : 'group/selection relative flex max-w-full flex-col items-end gap-1.5'}
>
<button
type="button"
aria-label={`Open ${attachment.name}`}
onClick={() => setActiveImageIndex(images.findIndex((image) => image.src === src))}
className={
isComposer
? 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)]'
: 'overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-sm transition-transform hover:scale-[1.01]'
}
>
<img
src={src}
alt={attachment.name}
className={
isComposer
? 'h-16 w-16 object-cover'
: 'max-h-[340px] w-full max-w-[360px] object-cover'
}
/>
</button>
{hasSelectionNote && (
<>
<span
aria-describedby={tooltipId}
aria-label={`Selection note: ${selectionNote}`}
title={selectionNote}
tabIndex={0}
className={[
'inline-flex h-7 max-w-[260px] items-center gap-1.5 rounded-full border',
'border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5',
'text-[12px] font-medium leading-none text-[var(--color-text-primary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
'transition-colors hover:border-[var(--color-brand)]/45 hover:bg-[var(--color-surface-container)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2',
].join(' ')}
>
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">
ads_click
</span>
<span className="min-w-0 truncate">{attachment.name}</span>
</span>
<span
id={tooltipId}
role="tooltip"
className={[
'pointer-events-none invisible absolute bottom-9 right-0 z-30 w-max max-w-[min(340px,calc(100vw-3rem))]',
'translate-y-1 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-high)] px-3 py-2',
'text-left text-[13px] leading-5 text-[var(--color-text-primary)] opacity-0 shadow-[var(--shadow-dropdown)]',
'transition-all duration-150 group-hover/selection:visible group-hover/selection:translate-y-0 group-hover/selection:opacity-100',
'group-focus-within/selection:visible group-focus-within/selection:translate-y-0 group-focus-within/selection:opacity-100',
].join(' ')}
>
<span className="block text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">
</span>
<span className="mt-1 block whitespace-pre-wrap break-words">
{selectionNote}
</span>
</span>
</>
)}
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${attachment.name}`}
>
×
</button>
)}
</div>
)
}
const lineLabel = attachment.lineStart
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
: ''
const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ')
const hasQuotePreview = !!quotePreview
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
className={[
'group/file inline-flex max-w-full min-w-0 border border-[var(--color-border)]',
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
hasQuotePreview
? 'items-start gap-2 rounded-[8px] px-2.5 py-2'
: 'h-9 items-center gap-2 rounded-full px-3',
].join(' ')}
>
<span className={`material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)] ${hasQuotePreview ? 'mt-0.5' : ''}`}>
{hasQuotePreview ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'}
</span>
<span className="min-w-0">
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">
{attachment.name}{lineLabel}
</span>
{hasQuotePreview && (
<span className="mt-0.5 block max-w-[320px] truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{quotePreview}
</span>
)}
</span>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className={`${hasQuotePreview ? 'mt-0.5' : 'ml-0.5'} flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]`}
aria-label={`Remove ${attachment.name}`}
>
<span className="material-symbols-outlined text-[17px]">close</span>
</button>
)}
</div>
)
})}
</div>
{activeImageIndex !== null && activeImageIndex >= 0 && (
<ImageGalleryModal
open={activeImageIndex !== null}
images={images}
activeIndex={activeImageIndex}
onClose={() => setActiveImageIndex(null)}
onSelect={setActiveImageIndex}
/>
)}
</>
)
}