Files

320 lines
8.8 KiB
TypeScript

import {
type MouseEvent as ReactMouseEvent,
useCallback,
useState,
} from 'react';
import { readAssetBytes } from '@/src/services/assetReadUrlService.ts';
import { copyTextToClipboard } from '@/src/services/clipboard.ts';
import {
type HostFileExportAudioRequest,
type HostFileExportImageRequest,
canUseNativeHostCapability,
exportHostAudioFile,
exportHostImageFile,
isNativeAppRuntime,
} from '@/src/services/host-bridge/hostBridge.ts';
import { getLayerAssetExtensionFromTypeOrSrc } from '../ImageCanvasExportModel.ts';
import {
contextAssetMediaSrc,
type EditorAgentContextAsset,
EditorAgentRightClickAction,
type RightClickMenuTarget,
} from './common.ts';
type RightClickMenuState = {
x: number;
y: number;
target: RightClickMenuTarget;
pendingAction: EditorAgentRightClickAction | null;
resultAction: EditorAgentRightClickAction | null;
result: 'success' | 'error' | null;
};
function sanitizeDownloadName(value: string, extension: string) {
const normalized = value
.trim()
.replace(/[<>:"/\\|?*]/gu, '-')
.replace(/\p{Cc}/gu, '-')
.replace(/[. ]+$/u, '')
.slice(0, 100);
const baseName = (normalized || 'Agent素材').replace(
/\.[a-z0-9]{1,8}$/iu,
'',
);
return extension ? `${baseName}.${extension}` : baseName;
}
function blobToBase64Data(blob: Blob) {
return new Promise<string>((resolve, reject) => {
if (typeof FileReader === 'undefined') {
reject(new Error('当前环境不支持文件编码'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('文件编码失败'));
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const base64Data = result.split(',')[1] ?? '';
if (base64Data) {
resolve(base64Data);
} else {
reject(new Error('文件编码失败'));
}
};
reader.readAsDataURL(blob);
});
}
function hostImageMimeTypeFromExtension(
extension: string,
): HostFileExportImageRequest['mimeType'] | null {
if (extension === 'png') {
return 'image/png';
}
if (extension === 'jpg' || extension === 'jpeg') {
return 'image/jpeg';
}
if (extension === 'webp') {
return 'image/webp';
}
return null;
}
function hostAudioMimeTypeFromExtension(
extension: string,
): HostFileExportAudioRequest['mimeType'] | null {
if (extension === 'mp3') {
return 'audio/mpeg';
}
if (extension === 'm4a' || extension === 'mp4') {
return 'audio/mp4';
}
if (extension === 'wav') {
return 'audio/wav';
}
if (extension === 'ogg') {
return 'audio/ogg';
}
if (extension === 'webm') {
return 'audio/webm';
}
return null;
}
function triggerBrowserDownload(blob: Blob, fileName: string) {
if (
typeof document === 'undefined' ||
typeof URL.createObjectURL !== 'function' ||
typeof URL.revokeObjectURL !== 'function'
) {
return false;
}
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 0);
return true;
}
async function convertImageBlobToPng(blob: Blob) {
if (blob.type.toLowerCase() === 'image/png') {
return blob;
}
if (
typeof createImageBitmap !== 'function' ||
typeof document === 'undefined'
) {
throw new Error('当前浏览器不支持复制此图片格式');
}
const bitmap = await createImageBitmap(blob);
try {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('图片转换失败');
}
context.drawImage(bitmap, 0, 0);
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((pngBlob) => {
if (pngBlob) {
resolve(pngBlob);
} else {
reject(new Error('图片转换失败'));
}
}, 'image/png');
});
} finally {
bitmap.close();
}
}
async function copyAssetImage(asset: EditorAgentContextAsset) {
if (
typeof navigator === 'undefined' ||
typeof navigator.clipboard?.write !== 'function' ||
typeof ClipboardItem === 'undefined'
) {
return false;
}
try {
const pngBlob = readAssetBytes(contextAssetMediaSrc(asset), {
objectKey: asset.objectKey,
}).then(async (response) => convertImageBlobToPng(await response.blob()));
// Clipboard write must start in the menu click's user-activation stack.
// Safari accepts promised ClipboardItem data and resolves it after asset loading/conversion.
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': pngBlob }),
]);
return true;
} catch {
return false;
}
}
async function downloadAsset(asset: EditorAgentContextAsset) {
try {
const source = contextAssetMediaSrc(asset);
const response = await readAssetBytes(source, {
objectKey: asset.objectKey,
});
const blob = await response.blob();
const extension = getLayerAssetExtensionFromTypeOrSrc(
asset.mediaType,
// application/octet-stream is impossible by contract
blob.type,
asset.objectKey ?? source,
);
const fileName = sanitizeDownloadName(asset.suggestedFileName, extension);
if (!isNativeAppRuntime()) {
return triggerBrowserDownload(blob, fileName);
}
if (
asset.mediaType === 'image' &&
canUseNativeHostCapability('file.exportImage')
) {
const mimeType = hostImageMimeTypeFromExtension(extension);
if (!mimeType) {
return false;
}
const base64Data = await blobToBase64Data(blob);
return Boolean(
await exportHostImageFile({ fileName, base64Data, mimeType }),
);
}
if (
asset.mediaType === 'audio' &&
canUseNativeHostCapability('file.exportAudio')
) {
const mimeType = hostAudioMimeTypeFromExtension(extension);
if (!mimeType) {
return false;
}
const base64Data = await blobToBase64Data(blob);
return Boolean(
await exportHostAudioFile({ fileName, base64Data, mimeType }),
);
}
// video is not yet supported to export on native shell
return false;
} catch {
return false;
}
}
export function useRightClickMenu({
onReferenceImage,
}: {
onReferenceImage?: (asset: EditorAgentContextAsset) => boolean;
} = {}) {
const [rightClickMenu, setRightClickMenu] =
useState<RightClickMenuState | null>(null);
const closeRightClickMenu = useCallback(() => {
setRightClickMenu(null);
}, []);
const openRightClickMenu = useCallback(
(event: ReactMouseEvent<HTMLElement>, target: RightClickMenuTarget) => {
event.preventDefault();
event.stopPropagation();
setRightClickMenu({
x: event.clientX,
y: event.clientY,
target,
pendingAction: null,
resultAction: null,
result: null,
});
},
[],
);
const runRightClickAction = useCallback(
async (action: EditorAgentRightClickAction) => {
if (!rightClickMenu || rightClickMenu.pendingAction) {
return;
}
const target = rightClickMenu.target;
setRightClickMenu((current) =>
current?.target === target
? {
...current,
pendingAction: action,
resultAction: null,
result: null,
}
: current,
);
const succeeded =
action === EditorAgentRightClickAction.CopyText &&
target.kind === 'text'
? await copyTextToClipboard(target.text)
: action === EditorAgentRightClickAction.CopyImage &&
target.kind === 'asset' &&
target.asset.mediaType === 'image'
? await copyAssetImage(target.asset)
: action === EditorAgentRightClickAction.ReferenceImage &&
target.kind === 'asset' &&
target.asset.mediaType === 'image' &&
contextAssetMediaSrc(target.asset).trim()
? (onReferenceImage?.(target.asset) ?? false)
: action === EditorAgentRightClickAction.DownloadAsset &&
target.kind === 'asset'
? await downloadAsset(target.asset)
: false;
setRightClickMenu((current) =>
current?.target === target && current.pendingAction === action
? succeeded
? null
: {
...current,
pendingAction: null,
resultAction: action,
result: 'error',
}
: current,
);
},
[onReferenceImage, rightClickMenu],
);
return {
rightClickMenu,
openRightClickMenu,
closeRightClickMenu,
runRightClickAction,
};
}