完善画布素材与抠图任务体验
素材库资产支持直接下载。 画布复制图片时同步写入系统剪贴板。 手动抠图请求直接进入画布任务列表并支持聚焦。
This commit is contained in:
@@ -66,6 +66,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
};
|
||||
|
||||
export function ImageCanvasAssetFolderSectionView({
|
||||
@@ -97,6 +98,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
}: ImageCanvasAssetFolderSectionViewProps) {
|
||||
return (
|
||||
<section
|
||||
@@ -256,6 +258,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
onDownloadAsset={onDownloadAsset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
toggleAllAssetsSelected: () => void;
|
||||
deleteSelectedAssets: () => void;
|
||||
closeAssetSelectionMode: () => void;
|
||||
@@ -143,6 +144,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
toggleAllAssetsSelected,
|
||||
deleteSelectedAssets,
|
||||
closeAssetSelectionMode,
|
||||
@@ -234,6 +236,7 @@ export function ImageCanvasAssetLibraryPanelView({
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
onDownloadAsset={onDownloadAsset}
|
||||
/>
|
||||
))}
|
||||
{isAssetSelectionMode ? (
|
||||
|
||||
@@ -76,6 +76,7 @@ function renderAssetRow({
|
||||
commitAssetRename = vi.fn(),
|
||||
deleteUploadedAsset = vi.fn(),
|
||||
setAssetPublicShowcaseEnabled = vi.fn(),
|
||||
onDownloadAsset = vi.fn(),
|
||||
}: {
|
||||
asset?: EditorAsset;
|
||||
isAssetSelectionMode?: boolean;
|
||||
@@ -90,6 +91,7 @@ function renderAssetRow({
|
||||
asset: EditorAsset,
|
||||
publicShowcaseEnabled: boolean,
|
||||
) => void;
|
||||
onDownloadAsset?: (asset: EditorAsset) => void;
|
||||
} = {}) {
|
||||
const props = {
|
||||
asset,
|
||||
@@ -111,6 +113,7 @@ function renderAssetRow({
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
};
|
||||
|
||||
render(<ImageCanvasAssetRowView {...props} />);
|
||||
@@ -128,6 +131,18 @@ describe('ImageCanvasAssetRowView', () => {
|
||||
expect(addAssetLayer).toHaveBeenCalledWith(asset);
|
||||
});
|
||||
|
||||
it('downloads the asset from the row actions', () => {
|
||||
const asset = createAsset();
|
||||
const onDownloadAsset = vi.fn();
|
||||
renderAssetRow({ asset, onDownloadAsset });
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '下载素材账号素材A' }),
|
||||
);
|
||||
|
||||
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
|
||||
});
|
||||
|
||||
it('selects the asset instead of adding it in selection mode', () => {
|
||||
const addAssetLayer = vi.fn();
|
||||
const toggleAssetSelected = vi.fn();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Music2, Pencil, Video, X } from 'lucide-react';
|
||||
import { Check, Download, Music2, Pencil, Video, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
@@ -49,6 +49,7 @@ export type ImageCanvasAssetRowViewProps = {
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
};
|
||||
|
||||
export function ImageCanvasAssetRowView({
|
||||
@@ -71,6 +72,7 @@ export function ImageCanvasAssetRowView({
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
}: ImageCanvasAssetRowViewProps) {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const [menuPosition, setMenuPosition] = useState<{
|
||||
@@ -204,6 +206,12 @@ export function ImageCanvasAssetRowView({
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-canvas-editor__asset-actions">
|
||||
<EditorIconButton
|
||||
label={`下载素材${asset.label}`}
|
||||
title="下载"
|
||||
icon={Download}
|
||||
onClick={() => onDownloadAsset(asset)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label={`重命名素材${asset.label}`}
|
||||
title="重命名"
|
||||
|
||||
@@ -296,6 +296,47 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(screen.getByAltText('画布图片:主视觉素材')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('downloads an asset directly from the asset library', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn(async (url: string) => {
|
||||
if (url === '/creation-type-references/puzzle.webp') {
|
||||
return new Response(new Blob(['asset'], { type: 'image/webp' }));
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
}) as typeof fetch;
|
||||
let downloadName = '';
|
||||
let downloadHref = '';
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:asset-library-download'),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(
|
||||
function click(this: HTMLAnchorElement) {
|
||||
downloadName = this.download;
|
||||
downloadHref = this.href;
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '下载素材拼图素材' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(downloadName).toBe('拼图素材.webp');
|
||||
});
|
||||
expect(downloadHref).toBe('blob:asset-library-download');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('collapses folders, creates upload folders, and deletes uploaded materials', async () => {
|
||||
const user = userEvent.setup();
|
||||
const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image');
|
||||
|
||||
@@ -80,6 +80,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
|
||||
setAssetPublicShowcaseEnabled: vi.fn(),
|
||||
toggleAssetSelected: vi.fn(),
|
||||
addAssetLayer: vi.fn(),
|
||||
onDownloadAsset: vi.fn(),
|
||||
toggleAllAssetsSelected: vi.fn(),
|
||||
deleteSelectedAssets: vi.fn(),
|
||||
closeAssetSelectionMode: vi.fn(),
|
||||
@@ -135,6 +136,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
hoveredLayerId: null,
|
||||
canvasMarquee: null,
|
||||
canvasGenerationDialogs: [],
|
||||
localTaskItems: [],
|
||||
isTaskSidebarOpen: false,
|
||||
generateDialog: null,
|
||||
cropExpandPanel: null,
|
||||
@@ -178,6 +180,7 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onGenerationFramePointerDown: vi.fn(),
|
||||
onActivateGenerationDialog: vi.fn(),
|
||||
onFocusExternalTask: vi.fn(),
|
||||
onFocusLocalTask: vi.fn(),
|
||||
onToggleTaskSidebar: vi.fn(),
|
||||
onCropExpandHandlePointerDown: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
|
||||
@@ -42,9 +42,11 @@ import type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasTaskListItem,
|
||||
CanvasTool,
|
||||
CanvasViewport,
|
||||
CharacterReferenceImage,
|
||||
EditorAsset,
|
||||
ImageContextMenuState,
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
@@ -167,6 +169,38 @@ async function readSystemClipboardImageFiles() {
|
||||
return imageFiles;
|
||||
}
|
||||
|
||||
function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
|
||||
return {
|
||||
id: `asset-download-${asset.id}`,
|
||||
resourceId:
|
||||
asset.sourceResourceId?.trim() || asset.assetObjectId?.trim() || asset.id,
|
||||
title: asset.label,
|
||||
src: asset.src,
|
||||
mediaType: asset.mediaType,
|
||||
thumbnailSrc: asset.thumbnailSrc,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
originalWidth: asset.width,
|
||||
originalHeight: asset.height,
|
||||
zIndex: 0,
|
||||
sourceType: asset.sourceType,
|
||||
prompt: asset.prompt ?? null,
|
||||
actualPrompt: asset.actualPrompt ?? null,
|
||||
model: asset.model ?? null,
|
||||
provider: asset.provider ?? null,
|
||||
taskId: asset.taskId ?? null,
|
||||
objectKey: asset.objectKey ?? null,
|
||||
assetObjectId: asset.assetObjectId ?? null,
|
||||
sourceResourceId: asset.sourceResourceId ?? null,
|
||||
sourceAssetId: asset.id,
|
||||
assetKind: asset.assetKind ?? null,
|
||||
generationInputs: asset.generationInputs ?? null,
|
||||
durationSeconds: asset.durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
export function ImageCanvasEditorView() {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
@@ -691,6 +725,14 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
[focusCanvasGenerationDialog, focusCanvasLayerById],
|
||||
);
|
||||
const focusLocalCanvasTask = useCallback(
|
||||
(task: CanvasTaskListItem) => {
|
||||
if (task.focusLayerId) {
|
||||
focusCanvasLayerById(task.focusLayerId);
|
||||
}
|
||||
},
|
||||
[focusCanvasLayerById],
|
||||
);
|
||||
const persistGeneratedAsset = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (authUiRef.current && !authUiRef.current.canAccessProtectedData) {
|
||||
@@ -928,6 +970,12 @@ export function ImageCanvasEditorView() {
|
||||
projectId,
|
||||
projectTitle,
|
||||
});
|
||||
const downloadAsset = useCallback(
|
||||
(asset: EditorAsset) => {
|
||||
exportLayerImage(createAssetActionLayer(asset));
|
||||
},
|
||||
[exportLayerImage],
|
||||
);
|
||||
const {
|
||||
uploadInputRef,
|
||||
uploadTarget,
|
||||
@@ -1631,6 +1679,7 @@ export function ImageCanvasEditorView() {
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset: downloadAsset,
|
||||
toggleAllAssetsSelected,
|
||||
deleteSelectedAssets,
|
||||
closeAssetSelectionMode,
|
||||
@@ -1686,6 +1735,7 @@ export function ImageCanvasEditorView() {
|
||||
hoveredLayerId,
|
||||
canvasMarquee,
|
||||
canvasGenerationDialogs,
|
||||
localTaskItems: generationSurface.backgroundRemovalTasks,
|
||||
isTaskSidebarOpen: generationSurface.isTaskSidebarOpen,
|
||||
generateDialog,
|
||||
cropExpandPanel: generationSurface.cropExpandPanel,
|
||||
@@ -1734,6 +1784,7 @@ export function ImageCanvasEditorView() {
|
||||
onGenerationFramePointerDown: handleGenerationFramePointerDown,
|
||||
onActivateGenerationDialog: activateCanvasGenerationDialog,
|
||||
onFocusExternalTask: focusExternalGenerationTask,
|
||||
onFocusLocalTask: focusLocalCanvasTask,
|
||||
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openLayerGenerationDialog,
|
||||
|
||||
@@ -118,6 +118,7 @@ function createSidebarProps(
|
||||
setAssetPublicShowcaseEnabled: vi.fn(),
|
||||
toggleAssetSelected: vi.fn(),
|
||||
addAssetLayer: vi.fn(),
|
||||
onDownloadAsset: vi.fn(),
|
||||
toggleAllAssetsSelected: vi.fn(),
|
||||
deleteSelectedAssets: vi.fn(),
|
||||
closeAssetSelectionMode: vi.fn(),
|
||||
@@ -189,6 +190,9 @@ describe('ImageCanvasSidebarView', () => {
|
||||
expect(
|
||||
within(folder).getByRole('button', { name: '上传到项目素材' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(folder).getByRole('button', { name: '下载素材账号素材A' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders asset editing inputs with sidebar text-field chrome', () => {
|
||||
|
||||
@@ -102,6 +102,7 @@ export type ImageCanvasSidebarViewProps = {
|
||||
) => void;
|
||||
toggleAssetSelected: (assetId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset) => void;
|
||||
onDownloadAsset: (asset: EditorAsset) => void;
|
||||
toggleAllAssetsSelected: () => void;
|
||||
deleteSelectedAssets: () => void;
|
||||
closeAssetSelectionMode: () => void;
|
||||
@@ -167,6 +168,7 @@ export function ImageCanvasSidebarView({
|
||||
setAssetPublicShowcaseEnabled,
|
||||
toggleAssetSelected,
|
||||
addAssetLayer,
|
||||
onDownloadAsset,
|
||||
toggleAllAssetsSelected,
|
||||
deleteSelectedAssets,
|
||||
closeAssetSelectionMode,
|
||||
@@ -321,6 +323,7 @@ export function ImageCanvasSidebarView({
|
||||
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
onDownloadAsset={onDownloadAsset}
|
||||
toggleAllAssetsSelected={toggleAllAssetsSelected}
|
||||
deleteSelectedAssets={deleteSelectedAssets}
|
||||
closeAssetSelectionMode={closeAssetSelectionMode}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasTaskListItem,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasTool,
|
||||
@@ -57,6 +58,7 @@ export type ImageCanvasStageViewProps = {
|
||||
hoveredLayerId: string | null;
|
||||
canvasMarquee: CanvasMarqueeState | null;
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
localTaskItems: CanvasTaskListItem[];
|
||||
isTaskSidebarOpen: boolean;
|
||||
generateDialog: GenerateDialogState | null;
|
||||
cropExpandPanel: CropExpandPanelState | null;
|
||||
@@ -116,6 +118,7 @@ export type ImageCanvasStageViewProps = {
|
||||
) => void;
|
||||
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
|
||||
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
|
||||
onFocusLocalTask: (task: CanvasTaskListItem) => void;
|
||||
onToggleTaskSidebar: () => void;
|
||||
onCropExpandHandlePointerDown: (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
@@ -195,6 +198,7 @@ export function ImageCanvasStageView({
|
||||
hoveredLayerId,
|
||||
canvasMarquee,
|
||||
canvasGenerationDialogs,
|
||||
localTaskItems,
|
||||
isTaskSidebarOpen,
|
||||
generateDialog,
|
||||
cropExpandPanel,
|
||||
@@ -239,6 +243,7 @@ export function ImageCanvasStageView({
|
||||
onGenerationFramePointerDown,
|
||||
onActivateGenerationDialog,
|
||||
onFocusExternalTask,
|
||||
onFocusLocalTask,
|
||||
onToggleTaskSidebar,
|
||||
onCropExpandHandlePointerDown,
|
||||
onOpenQuickEditPanel,
|
||||
@@ -438,9 +443,11 @@ export function ImageCanvasStageView({
|
||||
|
||||
<ImageCanvasTaskSidebarView
|
||||
projectId={projectId}
|
||||
localTasks={localTaskItems}
|
||||
open={isTaskSidebarOpen}
|
||||
onToggleOpen={onToggleTaskSidebar}
|
||||
onFocusExternalTask={onFocusExternalTask}
|
||||
onFocusLocalTask={onFocusLocalTask}
|
||||
/>
|
||||
|
||||
<ImageCanvasBottomToolbarView
|
||||
|
||||
@@ -286,4 +286,34 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
expect(screen.getByText('暂无任务')).toBeTruthy();
|
||||
expect(screen.queryByText(/已完成提示词/u)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows local background-removal tasks in the task list immediately', async () => {
|
||||
const focusLocalTask = vi.fn();
|
||||
const startedAt = Date.now() - 4000;
|
||||
const localTask = {
|
||||
id: 'background-removal-1',
|
||||
title: '去背景:源图',
|
||||
detail: '正在抠图',
|
||||
status: 'running' as const,
|
||||
startedAt,
|
||||
focusLayerId: 'layer-source',
|
||||
};
|
||||
|
||||
render(
|
||||
<ImageCanvasTaskSidebarView
|
||||
projectId="project-1"
|
||||
localTasks={[localTask]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
onFocusLocalTask={focusLocalTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('去背景:源图')).toBeTruthy();
|
||||
expect(screen.getByText(/正在抠图 · 已用时/u)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByText('去背景:源图'));
|
||||
expect(focusLocalTask).toHaveBeenCalledWith(localTask);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { listExternalGenerationTasks } from '../../services/external-generation';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasTaskStatus } from './ImageCanvasEditorTypes';
|
||||
import type {
|
||||
CanvasTaskListItem,
|
||||
CanvasTaskStatus,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
const COMPLETED_TASK_LIST_LIMIT = 20;
|
||||
const ACTIVE_TASK_LIST_LIMIT = 100;
|
||||
@@ -40,9 +43,11 @@ type TaskSidebarItem = {
|
||||
|
||||
type ImageCanvasTaskSidebarViewProps = {
|
||||
projectId?: string | null;
|
||||
localTasks?: CanvasTaskListItem[];
|
||||
open: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
|
||||
onFocusLocalTask?: (task: CanvasTaskListItem) => void;
|
||||
};
|
||||
|
||||
function parseTaskTimeMs(value?: string | null) {
|
||||
@@ -212,9 +217,11 @@ function mergeExternalTaskRefresh(
|
||||
|
||||
export function ImageCanvasTaskSidebarView({
|
||||
projectId,
|
||||
localTasks = [],
|
||||
open,
|
||||
onToggleOpen,
|
||||
onFocusExternalTask,
|
||||
onFocusLocalTask,
|
||||
}: ImageCanvasTaskSidebarViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<TaskSidebarTab>('active');
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -342,9 +349,9 @@ export function ImageCanvasTaskSidebarView({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasRunningTask = externalTasks.some(
|
||||
(task) => task.status === 'running',
|
||||
);
|
||||
const hasRunningTask =
|
||||
externalTasks.some((task) => task.status === 'running') ||
|
||||
localTasks.some((task) => task.status === 'running');
|
||||
if (!hasRunningTask) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -352,7 +359,7 @@ export function ImageCanvasTaskSidebarView({
|
||||
setNow(Date.now());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [externalTasks]);
|
||||
}, [externalTasks, localTasks]);
|
||||
|
||||
const items = useMemo<TaskSidebarItem[]>(() => {
|
||||
const externalVisibleTasks = mergeExternalTasks(
|
||||
@@ -401,8 +408,27 @@ export function ImageCanvasTaskSidebarView({
|
||||
0,
|
||||
};
|
||||
});
|
||||
return externalItems;
|
||||
}, [externalTasks, now, onFocusExternalTask]);
|
||||
const localItems = localTasks.map<TaskSidebarItem>((task) => ({
|
||||
id: `local-${task.id}`,
|
||||
title: task.title,
|
||||
detail: task.detail,
|
||||
status: task.status,
|
||||
startedAt: task.startedAt,
|
||||
finishedAt: task.finishedAt,
|
||||
elapsedMs:
|
||||
task.status === 'running'
|
||||
? now - task.startedAt
|
||||
: task.elapsedMs ??
|
||||
(task.finishedAt ? task.finishedAt - task.startedAt : undefined),
|
||||
errorMessage: task.errorMessage,
|
||||
onClick:
|
||||
task.focusLayerId && onFocusLocalTask
|
||||
? () => onFocusLocalTask(task)
|
||||
: undefined,
|
||||
sortTime: task.finishedAt ?? task.startedAt,
|
||||
}));
|
||||
return [...localItems, ...externalItems];
|
||||
}, [externalTasks, localTasks, now, onFocusExternalTask, onFocusLocalTask]);
|
||||
|
||||
const activeItems = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -9,8 +9,20 @@ import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { readLayerImageBlob } from './ImageCanvasExportModel';
|
||||
import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
||||
|
||||
vi.mock('./ImageCanvasExportModel', () => ({
|
||||
readLayerImageBlob: vi.fn(async (layer: CanvasLayer) => {
|
||||
const blob = new Blob([layer.id], { type: 'image/png' });
|
||||
Object.defineProperty(blob, 'layerId', {
|
||||
configurable: true,
|
||||
value: layer.id,
|
||||
});
|
||||
return blob;
|
||||
}),
|
||||
}));
|
||||
|
||||
function createLayer(id: string, x: number, zIndex: number): CanvasLayer {
|
||||
return {
|
||||
id,
|
||||
@@ -30,6 +42,7 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer {
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const originalClipboardItem = globalThis.ClipboardItem;
|
||||
const originalCreateImageBitmap = globalThis.createImageBitmap;
|
||||
|
||||
function installImageClipboardMock() {
|
||||
class ClipboardItemMock {
|
||||
@@ -283,10 +296,14 @@ afterEach(() => {
|
||||
configurable: true,
|
||||
value: originalClipboardItem,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'createImageBitmap', {
|
||||
configurable: true,
|
||||
value: originalCreateImageBitmap,
|
||||
});
|
||||
});
|
||||
|
||||
describe('useImageCanvasLayerCommands', () => {
|
||||
it('writes the first copied image layer to the system clipboard', async () => {
|
||||
it('writes the last copied image layer to the system clipboard', async () => {
|
||||
const write = installImageClipboardMock();
|
||||
render(<LayerCommandsHarness />);
|
||||
|
||||
@@ -297,9 +314,49 @@ describe('useImageCanvasLayerCommands', () => {
|
||||
| Array<{ items: Record<string, Blob> }>
|
||||
| undefined;
|
||||
expect(clipboardItems?.[0]?.items['image/png']).toBeInstanceOf(Blob);
|
||||
expect(readLayerImageBlob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'second' }),
|
||||
);
|
||||
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
|
||||
});
|
||||
|
||||
it('normalizes copied images to png for the system clipboard', async () => {
|
||||
const write = installImageClipboardMock();
|
||||
const drawImage = vi.fn();
|
||||
const toBlob = vi.fn((resolve: (blob: Blob) => void) =>
|
||||
resolve(new Blob(['png'], { type: 'image/png' })),
|
||||
);
|
||||
vi.mocked(readLayerImageBlob).mockResolvedValueOnce(
|
||||
new Blob(['webp'], { type: 'image/webp' }),
|
||||
);
|
||||
Object.defineProperty(globalThis, 'createImageBitmap', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({ width: 16, height: 12 })),
|
||||
});
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
|
||||
if (tagName === 'canvas') {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({ drawImage }),
|
||||
toBlob,
|
||||
} as unknown as HTMLCanvasElement;
|
||||
}
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
render(<LayerCommandsHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
|
||||
|
||||
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
||||
const clipboardItems = write.mock.calls[0]?.[0] as
|
||||
| Array<{ items: Record<string, Blob> }>
|
||||
| undefined;
|
||||
expect(clipboardItems?.[0]?.items['image/png']?.type).toBe('image/png');
|
||||
expect(drawImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps canvas copy when system image clipboard is unavailable', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -331,13 +388,21 @@ describe('useImageCanvasLayerCommands', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
|
||||
|
||||
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
||||
expect(readLayerImageBlob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'second' }),
|
||||
);
|
||||
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
|
||||
});
|
||||
|
||||
it('copies, cuts, and pastes context layers with history and selection updates', () => {
|
||||
it('copies, cuts, and pastes context layers with history and selection updates', async () => {
|
||||
const write = installImageClipboardMock();
|
||||
render(<LayerCommandsHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }));
|
||||
await waitFor(() => expect(write).toHaveBeenCalledTimes(1));
|
||||
expect(readLayerImageBlob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'second' }),
|
||||
);
|
||||
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
|
||||
expect(screen.getByTestId('context').textContent).toBe('closed');
|
||||
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
|
||||
|
||||
@@ -72,7 +72,27 @@ function isClipboardImageLayer(layer: CanvasLayer) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeClipboardImageBlob(blob: Blob) {
|
||||
async function normalizeClipboardImageBlob(blob: Blob) {
|
||||
if (blob.type === 'image/png') {
|
||||
return blob;
|
||||
}
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
try {
|
||||
const image = await createImageBitmap(blob);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
canvas.getContext('2d')?.drawImage(image, 0, 0);
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/png'),
|
||||
);
|
||||
if (pngBlob) {
|
||||
return pngBlob;
|
||||
}
|
||||
} catch {
|
||||
// 浏览器不支持当前图片解码时,回退到原始字节。
|
||||
}
|
||||
}
|
||||
if (!blob.type || blob.type === 'application/octet-stream') {
|
||||
return blob.slice(0, blob.size, 'image/png');
|
||||
}
|
||||
@@ -81,12 +101,14 @@ function normalizeClipboardImageBlob(blob: Blob) {
|
||||
|
||||
function copyLayerImageToSystemClipboard(layers: CanvasLayer[]) {
|
||||
void (async () => {
|
||||
const layer = layers.find(isClipboardImageLayer);
|
||||
const layer = layers.slice().reverse().find(isClipboardImageLayer);
|
||||
const clipboard = globalThis.navigator?.clipboard;
|
||||
if (!layer || !clipboard?.write || typeof ClipboardItem !== 'function') {
|
||||
return;
|
||||
}
|
||||
const blob = normalizeClipboardImageBlob(await readLayerImageBlob(layer));
|
||||
const blob = await normalizeClipboardImageBlob(
|
||||
await readLayerImageBlob(layer),
|
||||
);
|
||||
await clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
|
||||
})().catch(() => {
|
||||
// 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。
|
||||
|
||||
Reference in New Issue
Block a user