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((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((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(null); const closeRightClickMenu = useCallback(() => { setRightClickMenu(null); }, []); const openRightClickMenu = useCallback( (event: ReactMouseEvent, 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, }; }