feat(web): whole-page image drop, projected intake limits, and thumbnail tiling

Second alignment step for #2248: document-level drag intake behind the new
DropOverlay atom, lightbox close icon and shared dialog mask, DeepSeek Chat
thumbnail rules (single 240px long edge with ratio clamp, 64px tiles, merged
consecutive assistant images), image limits raised to 20/10MiB/100MiB and
published to clients as the imageLimits projection, whole-batch intake
pre-check with product-copy banners, and attachment-error reasons mapped to
localized copy.
This commit is contained in:
creatixchu
2026-08-12 12:31:52 +08:00
parent 4a8ee7674a
commit 2c9036b102
46 changed files with 999 additions and 171 deletions

View File

@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { DropOverlay } from '../src/DropOverlay.tsx'
afterEach(cleanup)
describe('DropOverlay', () => {
it('portals the invitation with its title and limits desc to the body', () => {
const view = render(
<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加', desc: '最多 20 张,每张 10MB' }} />,
)
const overlay = view.getByRole('status')
expect(overlay.parentElement).toBe(document.body)
expect(overlay.textContent).toContain('图片拖动到此处即可添加')
expect(overlay.textContent).toContain('最多 20 张,每张 10MB')
})
it('omits the desc line when none is resolved', () => {
const view = render(<DropOverlay disabled={false} labels={{ title: '图片拖动到此处即可添加' }} />)
expect(view.getByRole('status').textContent).toBe('图片拖动到此处即可添加')
})
it('drops the desc and switches the illustration while disabled', () => {
const enabled = render(
<DropOverlay disabled={false} labels={{ title: '拖入', desc: '限制' }} />,
)
const enabledSvg = enabled.getByRole('status').querySelector('svg')!.innerHTML
enabled.unmount()
const disabled = render(
<DropOverlay disabled labels={{ title: '当前无法添加图片', desc: '限制' }} />,
)
const overlay = disabled.getByRole('status')
expect(overlay.textContent).toBe('当前无法添加图片')
expect(overlay.querySelector('svg')!.innerHTML).not.toBe(enabledSvg)
})
})

View File

@@ -39,12 +39,13 @@ describe('ImageLightbox', () => {
}
})
it('closes on a backdrop press but not on a press over the image', () => {
it('closes on a mask press but not on a press over the image', () => {
const onClose = vi.fn()
const view = render(<ImageLightbox src="blob:original" alt="原图" labels={labels} onClose={onClose} />)
fireEvent.mouseDown(view.getByRole('img'))
expect(onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' }))
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
fireEvent.mouseDown(mask)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -29,7 +29,7 @@ const attachment = {
describe('MessageImage', () => {
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
const load = vi.fn().mockResolvedValue('blob:history')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(frame.getAttribute('style')).toContain('width: 240px')
expect(frame.getAttribute('style')).toContain('height: 120px')
@@ -44,7 +44,7 @@ describe('MessageImage', () => {
it('ignores a click while the thumbnail is still loading', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(view.getByText('图片加载中…')).toBeTruthy()
fireEvent.click(frame)
@@ -54,7 +54,7 @@ describe('MessageImage', () => {
it('falls back to the image label for an unnamed attachment', async () => {
const { name: _named, ...unnamed } = attachment
const load = vi.fn().mockResolvedValue('blob:unnamed')
const view = render(<MessageImage attachment={unnamed} load={load} labels={labels} />)
const view = render(<MessageImage attachment={unnamed} load={load} variant="single" labels={labels} />)
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
})
@@ -64,7 +64,7 @@ describe('MessageImage', () => {
.mockRejectedValueOnce(new Error('offline'))
.mockRejectedValueOnce(new Error('still offline'))
.mockResolvedValueOnce('blob:retry')
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
fireEvent.click(retry)
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
@@ -73,16 +73,52 @@ describe('MessageImage', () => {
expect(load).toHaveBeenCalledTimes(3)
})
it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
const load = vi.fn().mockResolvedValue('blob:ratio')
const tall = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 2000 }} load={load} variant="single" labels={labels} />,
)
const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
expect(tallFrame.getAttribute('style')).toContain('height: 240px')
await waitFor(() => { expect(tall.getByAltText('history.png')).toBeTruthy() })
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
tall.unmount()
const wide = render(
<MessageImage attachment={{ ...attachment, width: 4000, height: 100 }} load={load} variant="single" labels={labels} />,
)
const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
expect(wideFrame.getAttribute('style')).toContain('height: 60px')
await waitFor(() => { expect(wide.getByAltText('history.png')).toBeTruthy() })
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
wide.unmount()
const small = render(
<MessageImage attachment={{ ...attachment, width: 100, height: 100 }} load={load} variant="single" labels={labels} />,
)
const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
expect(smallFrame.getAttribute('style')).toContain('height: 100px')
})
it('renders a tile at the fixed square without inline sizing', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const view = render(<MessageImage attachment={attachment} load={load} variant="tile" labels={labels} />)
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
expect(frame.getAttribute('data-variant')).toBe('tile')
expect(frame.getAttribute('style')).toBeNull()
})
it('ignores a load settling after unmount', async () => {
let resolve: ((url: string) => void) | undefined
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
const view = render(<MessageImage attachment={attachment} load={load} labels={labels} />)
const view = render(<MessageImage attachment={attachment} load={load} variant="single" labels={labels} />)
view.unmount()
resolve?.('blob:late')
await Promise.resolve()
let reject: ((error: Error) => void) | undefined
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
const second = render(<MessageImage attachment={attachment} load={failing} labels={labels} />)
const second = render(<MessageImage attachment={attachment} load={failing} variant="single" labels={labels} />)
second.unmount()
reject?.(new Error('late failure'))
await Promise.resolve()
@@ -100,4 +136,15 @@ describe('ImageGallery', () => {
expect(view.container.querySelector('[data-align="end"]')).not.toBeNull()
await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) })
})
it('renders a lone image large and several images as square tiles', () => {
const load = vi.fn(() => new Promise<string>(() => {}))
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
lone.unmount()
const several = render(
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
)
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
})
})