接入桌面图片拖入创作槽位

CreativeImageInputPanel 订阅 file.imageDropped 桌面拖入事件

按拖入坐标命中主图卡片后转换为现有 File 回调

避免窗口级拖入被多个创作面板同时消费

更新组件测试和原生壳架构文档
This commit is contained in:
2026-06-18 03:27:55 +08:00
parent d5cab219a1
commit d261cc8d0b
5 changed files with 244 additions and 3 deletions
@@ -12,12 +12,14 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
canUseNativeHostCapability,
importHostImageFile,
subscribeHostImageDrop,
} from '../../services/host-bridge/hostBridge';
import { CreativeImageInputPanel } from './CreativeImageInputPanel';
vi.mock('../../services/host-bridge/hostBridge', () => ({
canUseNativeHostCapability: vi.fn(() => false),
importHostImageFile: vi.fn(),
subscribeHostImageDrop: vi.fn(() => () => undefined),
}));
vi.mock('../ResolvedAssetImage', () => ({
@@ -34,12 +36,22 @@ vi.mock('../ResolvedAssetImage', () => ({
const canUseNativeHostCapabilityMock = vi.mocked(canUseNativeHostCapability);
const importHostImageFileMock = vi.mocked(importHostImageFile);
const subscribeHostImageDropMock = vi.mocked(subscribeHostImageDrop);
type HostImageDropListener = Parameters<typeof subscribeHostImageDrop>[0];
afterEach(() => {
vi.clearAllMocks();
canUseNativeHostCapabilityMock.mockReturnValue(false);
subscribeHostImageDropMock.mockReturnValue(() => undefined);
});
function requireHostImageDropListener(
listener: HostImageDropListener | null,
) {
expect(listener).not.toBeNull();
return listener as HostImageDropListener;
}
test('creative image input panel handles reference uploads and preview', () => {
const onPromptReferenceFilesSelect = vi.fn();
const onPromptReferenceRemove = vi.fn();
@@ -901,3 +913,183 @@ test('creative image input panel keeps callbacks quiet when native image import
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
});
test('creative image input panel accepts desktop host image drops inside the main image card', async () => {
const onMainImageFileSelect = vi.fn();
let dropListener: HostImageDropListener | null = null;
canUseNativeHostCapabilityMock.mockImplementation(
(capability) =>
capability === 'file.importImage' || capability === 'file.imageDropped',
);
subscribeHostImageDropMock.mockImplementation((listener) => {
dropListener = listener;
return () => undefined;
});
const { container } = render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="dropped-main-image-upload-input"
promptTextareaId="dropped-native-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const card = container.querySelector('.creative-image-input-panel__image-card');
expect(card).not.toBeNull();
vi.spyOn(card as Element, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: 20,
right: 210,
bottom: 220,
width: 200,
height: 200,
x: 10,
y: 20,
toJSON: () => ({}),
});
const elementFromPointMock = vi.fn(() => card);
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: elementFromPointMock,
});
requireHostImageDropListener(dropListener)({
action: 'dropped',
fileName: '拖入图.webp',
mimeType: 'image/webp',
base64Data: 'ZHJvcA==',
bytes: 4,
position: {
x: 32,
y: 48,
},
});
await waitFor(() => {
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
});
const selectedFile = onMainImageFileSelect.mock.calls[0]?.[0] as File;
expect(selectedFile.name).toBe('拖入图.webp');
expect(selectedFile.type).toBe('image/webp');
Reflect.deleteProperty(document, 'elementFromPoint');
});
test('creative image input panel ignores desktop host image drops outside or behind another element', () => {
const onMainImageFileSelect = vi.fn();
let dropListener: HostImageDropListener | null = null;
canUseNativeHostCapabilityMock.mockImplementation(
(capability) =>
capability === 'file.importImage' || capability === 'file.imageDropped',
);
subscribeHostImageDropMock.mockImplementation((listener) => {
dropListener = listener;
return () => undefined;
});
const { container } = render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="ignored-drop-main-image-upload-input"
promptTextareaId="ignored-drop-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const card = container.querySelector('.creative-image-input-panel__image-card');
expect(card).not.toBeNull();
vi.spyOn(card as Element, 'getBoundingClientRect').mockReturnValue({
left: 10,
top: 20,
right: 210,
bottom: 220,
width: 200,
height: 200,
x: 10,
y: 20,
toJSON: () => ({}),
});
const emitDrop = requireHostImageDropListener(dropListener);
emitDrop({
action: 'dropped',
fileName: '卡片外.png',
mimeType: 'image/png',
base64Data: 'b3V0',
bytes: 3,
position: {
x: 4,
y: 48,
},
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
const overlay = document.createElement('div');
const elementFromPointMock = vi.fn(() => overlay);
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: elementFromPointMock,
});
emitDrop({
action: 'dropped',
fileName: '遮挡图.png',
mimeType: 'image/png',
base64Data: 'b3Zlcg==',
bytes: 4,
position: {
x: 32,
y: 48,
},
});
expect(onMainImageFileSelect).not.toHaveBeenCalled();
Reflect.deleteProperty(document, 'elementFromPoint');
});