320 lines
8.8 KiB
TypeScript
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,
|
|
};
|
|
}
|