From 5fce140e48d2db5faa853be1e16bcf7f81d8dd70 Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:15:58 +0800 Subject: [PATCH 1/3] fix(quick-start): use slider direction without follow-up --- frontend/src/pages/quick-start/index.test.tsx | 61 +++++++++--- frontend/src/pages/quick-start/index.tsx | 99 +++++++++---------- 2 files changed, 94 insertions(+), 66 deletions(-) diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 0edfa8c0..3bd30c6a 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -333,14 +333,12 @@ function renderInBrowserHistory( ) } -async function confirmAgentGeneration(movement: '单向' | '四向' | '八向' = '单向') { +async function confirmAgentGeneration() { const fill = await screen.findByRole('button', { name: '填入输入框' }) fireEvent.click(fill) const send = await screen.findByRole('button', { name: '发送生成' }) fireEvent.click(send) await act(async () => undefined) - fireEvent.click(await screen.findByRole('button', { name: movement })) - await act(async () => undefined) } function renderWithRunSwitcher( @@ -1033,6 +1031,42 @@ describe('QuickStartPage', () => { expect(window.sessionStorage.getItem(key)).toContain('"gameStyle":"pixel"') }) + it('keeps the slider direction across a refresh before proposal confirmation', async () => { + vi.useFakeTimers() + const service = serviceFor(null) + const startCharacterGeneration = vi.fn(() => new Promise<{ runId: string }>(() => undefined)) + const agent = agentFor({ startCharacterGeneration }) + window.history.replaceState(null, '', '/quick-start') + const firstView = renderInBrowserHistory(service, agent) + + fireEvent.click(screen.getByRole('button', { name: '生成方向,当前单向' })) + fireEvent.change(screen.getByRole('slider', { name: '生成方向' }), { + target: { value: '2' }, + }) + fireEvent.change(screen.getByRole('textbox', { name: '创作指令' }), { + target: { value: '云端工坊的银发机械师' }, + }) + fireEvent.click(screen.getByRole('button', { name: '生成角色' })) + await act(async () => undefined) + + const draftId = window.history.state?.windupQuickStartAgentDraftId + const key = `windup.quick-start.agent-chat.v2:draft:7:${draftId}` + expect(window.sessionStorage.getItem(key)).toContain('"directionalMovement":"eight-way"') + + firstView.unmount() + renderInBrowserHistory(service, agent) + fireEvent.click(screen.getByRole('button', { name: '填入输入框' })) + await act(async () => vi.advanceTimersByTimeAsync(760)) + fireEvent.click(screen.getByRole('button', { name: '发送生成' })) + await act(async () => undefined) + + expect(startCharacterGeneration).toHaveBeenCalledWith({ + prompt: '云端工坊的银发机械师', + directionalMovement: 'eight-way', + gameStyle: 'unspecified', + }) + }) + it('passes the chosen art style into every Agent planning turn', async () => { const planner = vi.fn(async (_input: PlannerInput) => ({ text: '想保留哪个特征?', @@ -1087,8 +1121,6 @@ describe('QuickStartPage', () => { }) fireEvent.click(screen.getByRole('button', { name: '发送生成' })) await act(async () => undefined) - fireEvent.click(screen.getByRole('button', { name: '单向' })) - await act(async () => undefined) await act(async () => vi.advanceTimersByTime(460)) const runConversation = window.localStorage.getItem( @@ -1149,13 +1181,17 @@ describe('QuickStartPage', () => { expect(window.localStorage.getItem(legacyKey)).toBeNull() }) - it('keeps the proposal in chat until the user fills, edits, and sends it', async () => { + it('starts with the slider direction after the user edits and sends the proposal', async () => { vi.useFakeTimers() const service = serviceFor(null) const startCharacterGeneration = vi.fn(() => new Promise<{ runId: string }>(() => undefined)) const agent = agentFor({ startCharacterGeneration }) renderAt('/quick-start', service, agent) + fireEvent.click(screen.getByRole('button', { name: '生成方向,当前单向' })) + fireEvent.change(screen.getByRole('slider', { name: '生成方向' }), { + target: { value: '2' }, + }) fireEvent.change(screen.getByRole('textbox', { name: '创作指令' }), { target: { value: '云端工坊的银发机械师' }, }) @@ -1196,7 +1232,7 @@ describe('QuickStartPage', () => { const fill = screen.getByRole('button', { name: '填入输入框' }) expect(fill.className).not.toContain('border') expect(fill.hasAttribute('data-inline-arrow-action')).toBe(true) - expect(fill.textContent).toContain('编辑后逐步确认') + expect(fill.textContent).toContain('编辑后发送生成') fireEvent.click(fill) expect(composer.dataset.promptState).toBe('rewriting') @@ -1218,12 +1254,9 @@ describe('QuickStartPage', () => { fireEvent.click(screen.getByRole('button', { name: '发送生成' })) await act(async () => undefined) - expect(screen.getByText('最后确认一下:需要单向、四向还是八向?')).toBeTruthy() + expect(screen.queryByText('最后确认一下:需要单向、四向还是八向?')).toBeNull() + expect(screen.queryByRole('group', { name: '选择生成方向' })).toBeNull() expect(input.hasAttribute('disabled')).toBe(true) - expect(startCharacterGeneration).not.toHaveBeenCalled() - - fireEvent.click(screen.getByRole('button', { name: '八向' })) - await act(async () => undefined) expect(startCharacterGeneration).toHaveBeenCalledWith({ prompt: '云端工坊的银发机械师,佩戴黄铜护目镜', directionalMovement: 'eight-way', @@ -1824,8 +1857,6 @@ describe('QuickStartPage', () => { expect(screen.getByRole('button', { name: '发送生成' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '发送生成' })) await act(async () => undefined) - fireEvent.click(screen.getByRole('button', { name: '单向' })) - await act(async () => undefined) const entry = screen.getByLabelText('创作指令').closest('[data-layout="quick-start-entry"]') expect(entry?.getAttribute('data-transition')).toBe('leaving') @@ -2099,7 +2130,7 @@ describe('QuickStartPage', () => { }) fireEvent.click(screen.getByRole('button', { name: '生成角色' })) expect(startCharacterGeneration).not.toHaveBeenCalled() - await confirmAgentGeneration('四向') + await confirmAgentGeneration() await waitFor(() => expect(startCharacterGeneration).toHaveBeenCalledWith({ prompt: '16-bit 日式 RPG 像素风,清晰轮廓,明亮配色', diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 783ef940..47213730 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -183,6 +183,8 @@ type AgentConversationRecord = { turns: readonly AgentConversationTurn[] /** 入口处选的画风;不随草稿存住的话,刷新后画风选择器已隐藏而值悄悄回到不指定。 */ gameStyle?: ArtStyle + /** 入口滑块选的方向;进入对话后滑块隐藏,刷新时必须恢复原值。 */ + directionalMovement?: DirectionalMovement } type AgentConversationStorageName = 'localStorage' | 'sessionStorage' @@ -226,6 +228,24 @@ function readAgentDraftGameStyle(key: string): ArtStyle { } } +function readAgentDraftDirectionalMovement(key: string): DirectionalMovement { + try { + const stored = window.sessionStorage.getItem(key) + if (!stored) return 'single' + const parsed: unknown = JSON.parse(stored) + if (typeof parsed !== 'object' || parsed === null || !('directionalMovement' in parsed)) { + return 'single' + } + return QUICK_START_DIRECTIONAL_MOVEMENTS.includes( + parsed.directionalMovement as DirectionalMovement, + ) + ? (parsed.directionalMovement as DirectionalMovement) + : 'single' + } catch { + return 'single' + } +} + function createAgentDraftId(): string { const draftId = window.crypto.randomUUID() try { @@ -636,8 +656,16 @@ function QuickStartInput({ }) { const navigate = useNavigate() const [prompt, setPrompt] = useState('') - const [directionalMovement, setDirectionalMovement] = useState('single') - const [confirmedPrompt, setConfirmedPrompt] = useState(null) + const [directionalMovement, setDirectionalMovement] = useState(() => { + const draftId = readAgentDraftId() + return draftId + ? readAgentDraftDirectionalMovement( + agentDraftConversationStorageKey(activeRunUserId, draftId), + ) + : 'single' + }) + const directionalMovementRef = useRef(directionalMovement) + directionalMovementRef.current = directionalMovement const [templateFile, setTemplateFile] = useState(null) const [gameStyle, setGameStyle] = useState(() => { const draftId = readAgentDraftId() @@ -651,7 +679,7 @@ function QuickStartInput({ const [revealingFirstAgentTurn, setRevealingFirstAgentTurn] = useState(false) const [entryTransition, setEntryTransition] = useState<'idle' | 'leaving'>('idle') const [promptState, setPromptState] = useState< - 'collecting' | 'rewriting' | 'ready' | 'direction' | 'confirmed' + 'collecting' | 'rewriting' | 'ready' | 'confirmed' >('collecting') const [error, setError] = useState(null) const draftIdRef = useRef(readAgentDraftId()) @@ -686,12 +714,7 @@ function QuickStartInput({ const agentPlanning = agentSession.state.status === 'planning' const agentThinking = agentPlanning || revealingFirstAgentTurn const generationStarting = promptState === 'confirmed' - const entryBusy = - submitting || - agentThinking || - promptState === 'rewriting' || - promptState === 'direction' || - generationStarting + const entryBusy = submitting || agentThinking || promptState === 'rewriting' || generationStarting const entryCanInterrupt = submitting || agentPlanning const hasPrompt = Boolean(prompt.trim()) const hasConversation = conversationTurns.length > 0 @@ -716,7 +739,11 @@ function QuickStartInput({ writeAgentConversation( 'sessionStorage', agentDraftConversationStorageKey(activeRunUserId, draftId), - { turns, gameStyle: gameStyleRef.current }, + { + turns, + gameStyle: gameStyleRef.current, + directionalMovement: directionalMovementRef.current, + }, ) }, [activeRunUserId, ensureDraftId], @@ -929,14 +956,15 @@ function QuickStartInput({ if (agentSession.state.status === 'proposal' && promptState === 'ready') { if (!normalizedPrompt) return - setConfirmedPrompt(normalizedPrompt) - setPrompt('') - setPromptState('direction') - appendConversationTurn({ - role: 'assistant', - content: '最后确认一下:需要单向、四向还是八向?', - kind: 'clarification', - }) + setPromptState('confirmed') + try { + const result = await agentSession.confirmProposal(normalizedPrompt, directionalMovement, { + gameStyle, + }) + if (result.kind === 'generated') await handoffGenerated(result) + } catch { + setPromptState('ready') + } return } @@ -1025,25 +1053,8 @@ function QuickStartInput({ } } - async function chooseDirectionalMovement(movement: DirectionalMovement) { - if (!confirmedPrompt || promptState !== 'direction') return - setDirectionalMovement(movement) - appendConversationTurn({ role: 'user', content: DIRECTIONAL_MOVEMENT[movement] }) - setPromptState('confirmed') - try { - const result = await agentSession.confirmProposal(confirmedPrompt, movement, { gameStyle }) - if (result.kind === 'generated') await handoffGenerated(result) - } catch { - setPromptState('direction') - } - } - const inputLocked = - submitting || - agentThinking || - promptState === 'rewriting' || - promptState === 'direction' || - generationStarting + submitting || agentThinking || promptState === 'rewriting' || generationStarting const awaitingGenerationConfirmation = agentSession.state.status === 'proposal' && promptState === 'ready' const buttonLabel = submitting @@ -1154,20 +1165,6 @@ function QuickStartInput({ ) : null} - {promptState === 'direction' ? ( -
- {QUICK_START_DIRECTIONAL_MOVEMENTS.map((movement) => ( - - ))} -
- ) : null} ) : ( <> @@ -1529,7 +1526,7 @@ function PromptProposal({ ) : null} - 编辑后逐步确认 + 编辑后发送生成 ) : status === 'superseded' ? ( From 1f9d74691e13922d9a989166a925c45192a1132e Mon Sep 17 00:00:00 2001 From: xyh202131 <246811510+xyh202131@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:27:42 +0800 Subject: [PATCH 2/3] fix(quick-start): preserve draft controls atomically --- frontend/src/pages/quick-start/index.test.tsx | 38 +++++++++++++++++++ frontend/src/pages/quick-start/index.tsx | 37 +++++++++--------- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 3bd30c6a..53db5b03 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -1067,6 +1067,44 @@ describe('QuickStartPage', () => { }) }) + it('persists a slider-only direction change across a refresh', () => { + const service = serviceFor(null) + const agent = agentFor() + window.history.replaceState(null, '', '/quick-start') + const firstView = renderInBrowserHistory(service, agent) + + fireEvent.click(screen.getByRole('button', { name: '生成方向,当前单向' })) + fireEvent.change(screen.getByRole('slider', { name: '生成方向' }), { + target: { value: '2' }, + }) + + const draftId = window.history.state?.windupQuickStartAgentDraftId + const key = `windup.quick-start.agent-chat.v2:draft:7:${draftId}` + expect(window.sessionStorage.getItem(key)).toContain('"directionalMovement":"eight-way"') + + firstView.unmount() + renderInBrowserHistory(service, agent) + expect(screen.getByRole('button', { name: '生成方向,当前八向' })).toBeTruthy() + }) + + it('preserves the slider direction when changing the draft art style', () => { + renderAt('/quick-start', serviceFor(null), agentFor()) + + fireEvent.click(screen.getByRole('button', { name: '生成方向,当前单向' })) + fireEvent.change(screen.getByRole('slider', { name: '生成方向' }), { + target: { value: '2' }, + }) + fireEvent.click(screen.getByRole('button', { name: '选择画风,当前不指定' })) + fireEvent.click(screen.getByRole('menuitemradio', { name: '像素' })) + + const draftId = window.history.state?.windupQuickStartAgentDraftId + const key = `windup.quick-start.agent-chat.v2:draft:7:${draftId}` + expect(JSON.parse(window.sessionStorage.getItem(key) ?? '{}')).toMatchObject({ + gameStyle: 'pixel', + directionalMovement: 'eight-way', + }) + }) + it('passes the chosen art style into every Agent planning turn', async () => { const planner = vi.fn(async (_input: PlannerInput) => ({ text: '想保留哪个特征?', diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 47213730..c1fbaaad 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -732,8 +732,9 @@ function QuickStartInput({ return draftId }, []) - const persistDraftConversation = useCallback( - (turns: readonly AgentConversationTurn[]) => { + const persistAgentDraft = useCallback( + (updates: Partial = {}) => { + const turns = updates.turns ?? conversationTurnsRef.current conversationTurnsRef.current = turns const draftId = ensureDraftId() writeAgentConversation( @@ -741,8 +742,8 @@ function QuickStartInput({ agentDraftConversationStorageKey(activeRunUserId, draftId), { turns, - gameStyle: gameStyleRef.current, - directionalMovement: directionalMovementRef.current, + gameStyle: updates.gameStyle ?? gameStyleRef.current, + directionalMovement: updates.directionalMovement ?? directionalMovementRef.current, }, ) }, @@ -775,9 +776,9 @@ function QuickStartInput({ const next = [...conversationTurnsRef.current, turn] conversationTurnsRef.current = next setConversationTurns(next) - persistDraftConversation(next) + persistAgentDraft({ turns: next }) }, - [persistDraftConversation], + [persistAgentDraft], ) async function revealFirstAgentTurn(turn: AgentConversationTurn) { @@ -838,7 +839,7 @@ function QuickStartInput({ ) conversationTurnsRef.current = next setConversationTurns(next) - persistDraftConversation(next) + persistAgentDraft({ turns: next }) return next } @@ -895,16 +896,16 @@ function QuickStartInput({ } function chooseGameStyle(next: ArtStyle) { + gameStyleRef.current = next setGameStyle(next) setStyleMenuOpen(false) - const draftId = draftIdRef.current - if (draftId) { - writeAgentConversation( - 'sessionStorage', - agentDraftConversationStorageKey(activeRunUserId, draftId), - { turns: conversationTurnsRef.current, gameStyle: next }, - ) - } + persistAgentDraft({ gameStyle: next }) + } + + function chooseDirectionalMovement(next: DirectionalMovement) { + directionalMovementRef.current = next + setDirectionalMovement(next) + persistAgentDraft({ directionalMovement: next }) } function stopEntryWork() { @@ -922,7 +923,7 @@ function QuickStartInput({ const nextTurns = turns.slice(0, -1) conversationTurnsRef.current = nextTurns setConversationTurns(nextTurns) - persistDraftConversation(nextTurns) + persistAgentDraft({ turns: nextTurns }) } } pendingPrompt.current = null @@ -1440,7 +1441,7 @@ function QuickStartInput({ const index = Math.round(Number(event.currentTarget.value)) setDirectionDragging(false) setDirectionSliderValue(index) - setDirectionalMovement( + chooseDirectionalMovement( QUICK_START_DIRECTIONAL_MOVEMENTS[index] ?? 'single', ) }} @@ -1453,7 +1454,7 @@ function QuickStartInput({ onChange={(event) => { const value = Number(event.target.value) setDirectionSliderValue(value) - setDirectionalMovement( + chooseDirectionalMovement( QUICK_START_DIRECTIONAL_MOVEMENTS[Math.round(value)] ?? 'single', ) }} From 440cf1fa66efff5a557635c976232a12dde99a3e Mon Sep 17 00:00:00 2001 From: xyh202131 Date: Wed, 26 Aug 2026 17:05:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(upload):=20=E6=94=AF=E6=8C=81=E8=A7=92?= =?UTF-8?q?=E8=89=B2=E5=9B=BE=E7=89=87=E6=8B=96=E6=8B=BD=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/quick-start/index.test.tsx | 32 +++++++ frontend/src/pages/quick-start/index.tsx | 31 ++++++- .../src/pages/workflow-editor/index.test.tsx | 42 +++++++++ frontend/src/pages/workflow-editor/index.tsx | 72 ++++++++++++--- frontend/src/shared/ui/image-drop-target.ts | 89 +++++++++++++++++++ frontend/src/shared/ui/index.ts | 7 ++ 6 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 frontend/src/shared/ui/image-drop-target.ts diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 34f30567..af050173 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -2630,6 +2630,38 @@ describe('QuickStartPage', () => { await waitFor(() => expect(service.dispose).toHaveBeenCalledTimes(2)) }) + it('accepts a supported role template dropped anywhere on the entry composer', () => { + renderAt('/quick-start', serviceFor(null)) + const dropzone = screen.getByRole('form', { name: '角色母版图片上传区' }) + const file = new File(['pixels'], 'hero.webp', { type: 'image/webp' }) + + expect(dropzone.textContent).toContain('PNG、JPG、GIF、WEBP') + expect(dropzone.textContent).toContain('10 MB') + + fireEvent.dragEnter(dropzone, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + expect(screen.getByText('松开以添加角色母版')).toBeTruthy() + + fireEvent.drop(dropzone, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + expect(screen.getByText('hero.webp')).toBeTruthy() + }) + + it('rejects unsupported files dropped on the entry composer', () => { + renderAt('/quick-start', serviceFor(null)) + const dropzone = screen.getByRole('form', { name: '角色母版图片上传区' }) + const file = new File(['not-an-image'], 'notes.txt', { type: 'text/plain' }) + + fireEvent.drop(dropzone, { + dataTransfer: { files: [file], types: ['Files'] }, + }) + + expect(screen.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、GIF、WEBP') + expect(screen.queryByText('notes.txt')).toBeNull() + }) + it('shows entry errors and supports removing an uploaded template', async () => { const service = serviceFor(null) const agent = agentFor({ diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index b5c6a5d8..a24ae9ca 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -68,9 +68,12 @@ import { FrameAnimationPlayer, GenerationPreviewCard, GenerationProgressCopy, + IMAGE_UPLOAD_ACCEPT, + IMAGE_UPLOAD_HINT, KineticCopyCycle, productPopoverClass, productPopoverMotionClass, + useImageDropTarget, useProductPopoverMotion, type KineticCopyMessage, } from '@/shared/ui' @@ -990,8 +993,8 @@ function QuickStartInput({ function selectTemplateFile(event: ChangeEvent) { if (entryBusy) return const selected = event.target.files?.[0] ?? null - setTemplateFile(selected) - setError(null) + event.target.value = '' + if (selected) templateDropTarget.selectFile(selected) } function removeTemplateFile() { @@ -999,6 +1002,15 @@ function QuickStartInput({ if (fileInput.current) fileInput.current.value = '' } + const templateDropTarget = useImageDropTarget({ + disabled: entryBusy || hasConversation, + onFile(file) { + setTemplateFile(file) + setError(null) + }, + onError: setError, + }) + function chooseGameStyle(next: ArtStyle) { gameStyleRef.current = next setGameStyle(next) @@ -1305,9 +1317,17 @@ function QuickStartInput({
void submit(event)} autoComplete="off" + aria-label="角色母版图片上传区" data-prompt-state={promptState} + data-drag-active={templateDropTarget.isDragging} + {...templateDropTarget.dropTargetProps} className="quick-start-agent-composer relative flex flex-col" > + {templateDropTarget.isDragging ? ( +
+ 松开以添加角色母版 +
+ ) : null}
角色参考图(选填) - { - const file = event.currentTarget.files?.[0] - event.currentTarget.value = '' - if (file) uploadReferenceImage(file) - }} - /> +
+ { + const file = event.currentTarget.files?.[0] + event.currentTarget.value = '' + if (file) referenceDropTarget.selectFile(file) + }} + /> + +
{uploadingReference ? ( 正在上传参考图… diff --git a/frontend/src/shared/ui/image-drop-target.ts b/frontend/src/shared/ui/image-drop-target.ts new file mode 100644 index 00000000..dd791c01 --- /dev/null +++ b/frontend/src/shared/ui/image-drop-target.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react' + +export const IMAGE_UPLOAD_ACCEPT = 'image/png,image/jpeg,image/gif,image/webp' +export const IMAGE_UPLOAD_HINT = '支持 PNG、JPG、GIF、WEBP,单张不超过 10 MB' + +const IMAGE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024 +const IMAGE_UPLOAD_TYPES = new Set(IMAGE_UPLOAD_ACCEPT.split(',')) + +export interface ImageDropTargetOptions { + disabled?: boolean + onFile(file: File): void + onError(message: string): void +} + +export function imageUploadError(file: File): string | null { + if (!IMAGE_UPLOAD_TYPES.has(file.type)) return '仅支持 PNG、JPG、GIF、WEBP 图片' + if (file.size > IMAGE_UPLOAD_MAX_BYTES) return '图片不能超过 10 MB' + return null +} + +function isFileDrag(event: DragEvent) { + return Array.from(event.dataTransfer.types).includes('Files') +} + +export function useImageDropTarget({ disabled = false, onFile, onError }: ImageDropTargetOptions) { + const [isDragging, setIsDragging] = useState(false) + const dragDepth = useRef(0) + + useEffect(() => { + if (!disabled) return + dragDepth.current = 0 + setIsDragging(false) + }, [disabled]) + + const selectFile = useCallback( + (file: File) => { + if (disabled) return false + const message = imageUploadError(file) + if (message) { + onError(message) + return false + } + onFile(file) + return true + }, + [disabled, onError, onFile], + ) + + const onDragEnter = useCallback( + (event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + if (disabled) return + dragDepth.current += 1 + setIsDragging(true) + }, + [disabled], + ) + + const onDragOver = useCallback((event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + }, []) + + const onDragLeave = useCallback((event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + dragDepth.current = Math.max(0, dragDepth.current - 1) + if (dragDepth.current === 0) setIsDragging(false) + }, []) + + const onDrop = useCallback( + (event: DragEvent) => { + if (!isFileDrag(event)) return + event.preventDefault() + dragDepth.current = 0 + setIsDragging(false) + const file = event.dataTransfer.files[0] + if (file) selectFile(file) + }, + [selectFile], + ) + + return { + isDragging, + selectFile, + dropTargetProps: { onDragEnter, onDragOver, onDragLeave, onDrop }, + } as const +} diff --git a/frontend/src/shared/ui/index.ts b/frontend/src/shared/ui/index.ts index b7635a9d..931f933e 100644 --- a/frontend/src/shared/ui/index.ts +++ b/frontend/src/shared/ui/index.ts @@ -26,3 +26,10 @@ export { } from './product-control' export type { ProductControlVariant, ProductPopoverMotionState } from './product-control' export { useProductPopoverMotion } from './product-popover-motion' +export { + IMAGE_UPLOAD_ACCEPT, + IMAGE_UPLOAD_HINT, + imageUploadError, + useImageDropTarget, +} from './image-drop-target' +export type { ImageDropTargetOptions } from './image-drop-target'