接入桌面图片拖入创作槽位
CreativeImageInputPanel 订阅 file.imageDropped 桌面拖入事件 按拖入坐标命中主图卡片后转换为现有 File 回调 避免窗口级拖入被多个创作面板同时消费 更新组件测试和原生壳架构文档
This commit is contained in:
@@ -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');
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
canUseNativeHostCapability,
|
||||
type HostFileImportImageResult,
|
||||
importHostImageFile,
|
||||
subscribeHostImageDrop,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
@@ -144,6 +145,7 @@ export function CreativeImageInputPanel({
|
||||
onHistoryClick,
|
||||
onSubmit,
|
||||
}: CreativeImageInputPanelProps) {
|
||||
const mainImageCardRef = useRef<HTMLDivElement | null>(null);
|
||||
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptReferenceInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewReferenceImage, setPreviewReferenceImage] =
|
||||
@@ -166,6 +168,8 @@ export function CreativeImageInputPanel({
|
||||
const shouldShowMainImageUploadButton =
|
||||
isMainImageUploadEnabled && shouldPreviewMainImage;
|
||||
const canImportHostImage = canUseNativeHostCapability('file.importImage');
|
||||
const canReceiveHostImageDrop =
|
||||
canUseNativeHostCapability('file.imageDropped');
|
||||
const promptReferenceInputId = `${mainImageInputId}-prompt-reference`;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -187,6 +191,50 @@ export function CreativeImageInputPanel({
|
||||
}
|
||||
}, [previewReferenceImage, promptReferenceImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReceiveHostImageDrop || disabled || !isMainImageUploadEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return subscribeHostImageDrop((payload) => {
|
||||
const card = mainImageCardRef.current;
|
||||
const position = payload.position;
|
||||
if (!card || !position) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = card.getBoundingClientRect();
|
||||
const isInsideCard =
|
||||
position.x >= bounds.left &&
|
||||
position.x <= bounds.right &&
|
||||
position.y >= bounds.top &&
|
||||
position.y <= bounds.bottom;
|
||||
if (!isInsideCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const topElement =
|
||||
typeof document.elementFromPoint === 'function'
|
||||
? document.elementFromPoint(position.x, position.y)
|
||||
: null;
|
||||
// 中文注释:桌面拖入是窗口级事件;只让坐标命中的最上层主图槽位消费,避免多个创作面板同时接收同一张图。
|
||||
if (topElement && !card.contains(topElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onMainImageFileSelect(hostImageImportResultToFile(payload));
|
||||
} catch {
|
||||
// 中文注释:宿主已校验图片类型和体积;这里仅兜住浏览器 File 构造异常,保持当前表单状态。
|
||||
}
|
||||
});
|
||||
}, [
|
||||
canReceiveHostImageDrop,
|
||||
disabled,
|
||||
isMainImageUploadEnabled,
|
||||
onMainImageFileSelect,
|
||||
]);
|
||||
|
||||
const bodyClassName = fillHeight
|
||||
? 'creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1'
|
||||
: 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1';
|
||||
@@ -282,7 +330,7 @@ export function CreativeImageInputPanel({
|
||||
{labels.imageField}
|
||||
</PlatformFieldLabel>
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
<div ref={mainImageCardRef} className={imageCardClassName}>
|
||||
{isMainImageUploadEnabled ? (
|
||||
<input
|
||||
ref={mainImageInputRef}
|
||||
|
||||
Reference in New Issue
Block a user