完善画布素材与抠图任务体验

素材库资产支持直接下载。

画布复制图片时同步写入系统剪贴板。

手动抠图请求直接进入画布任务列表并支持聚焦。
This commit is contained in:
2026-06-29 19:53:05 +08:00
parent dbf38f3a8c
commit 10b170e7a2
14 changed files with 294 additions and 13 deletions
@@ -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(() => {
// 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。