import { createAndSyncImage, createFileUploadRecord, getShortUrl, saveDomiBoardContent, } from '../service/api' import { type DominoStore, type ImageElement, type SceneElement, getDominoDOM, } from '../components/canvas' import { uploadFile } from './upload' import { filename } from './filename' import { blob2File } from './mark-image' export function getCTMPoint(target: SVGSVGElement, e: React.PointerEvent) { const point = target.createSVGPoint() point.x = e.clientX point.y = e.clientY // 获取从 SVG 坐标系到屏幕坐标系的变换矩阵 const ctm = target.getScreenCTM() if (!ctm) return { x: e.clientX, y: e.clientY } return point.matrixTransform(ctm.inverse()) } export function flyParabola( label: string | number, from: { x: number; y: number }, to: { x: number; y: number }, onComplete?: () => void, ) { const dx = to.x - from.x const dy = to.y - from.y const distance = Math.sqrt(dx * dx + dy * dy) const height = Math.min(120, distance * 0.5) const duration = 200 + Math.min(distance * 0.5, 500) const step = 16.6 / duration let t = 0 const el = document.createElement('div') el.className = 'fixed left-0 top-0 z-[1000] flex h-[20px] w-[20px] items-center justify-center rounded-full text-[12px] pointer-events-none' Object.assign(el.style, { color: 'var(--primary-foreground)', background: 'var(--editor-accent)', boxShadow: '0 2px 8px var(--editor-shadow)', border: '1px solid rgba(255,255,255,0.85)', }) el.innerText = label.toString() document.body.appendChild(el) function frame() { if (!el.isConnected) return t += step if (t > 1) t = 1 const x = from.x + dx * t const y = from.y + dy * t - height * 4 * t * (1 - t) el.style.transform = `translate(${x}px, ${y}px)` if (t < 1) { requestAnimationFrame(frame) } else { el.remove() onComplete?.() } } requestAnimationFrame(frame) } export async function uploadImage(taskId: string, file: File) { const { name, extension } = filename(file.name) const fileName = `${name}-${Date.now().toString(36)}.${extension}` const filePath = `super_agent/user_upload_file/${fileName}` await uploadFile(file, filePath) const record = await createAndSyncImage({ task_id: taskId, file_url: filePath, file_name: fileName, file_byte_size: file.size, file_type: file.type, }) return { url: record.url, file_id: record.upload_record_id, file_path: record.sandbox_path, size: file.size, } } export async function uploadMarkImage(blob: Blob, name: string) { const uuid = Math.random().toString(36).substring(2, 15) const extension = 'png' const MAGIC_STRING = '_$_marked_$_' const file = blob2File(blob, `${name}-marked.${extension}`) const filePath = `capri/upload/mark/${MAGIC_STRING}-${uuid}.png` const result = await uploadFile(file, filePath) const randomID = Math.random().toString(36).substring(2, 5) const fileName = [MAGIC_STRING, name, randomID].join('-') const record = await createFileUploadRecord({ file_url: filePath, file_name: [fileName, extension].join('.'), file_byte_size: file.size, file_type: file.type, conversation_id: uuid, }) return { file_url: result.url, file_id: (record as any).file_upload_record_id, file_path: filePath, file_name: fileName, file_type: 'png', size: file.size, } } /** * 获取图片像素尺寸 */ export async function getImageDimension(url: string, elementId?: string) { // 1. 尝试从 DOM 直接读取(最快) if (elementId) { const img = getImgElement(elementId) if (img && img.complete && img.naturalWidth > 0) { return { width: img.naturalWidth, height: img.naturalHeight } } } // 2. 兜底:通过 Image 对象加载 return new Promise<{ width: number; height: number }>((resolve, reject) => { const img = new Image() img.onload = () => resolve({ width: img.width, height: img.height }) img.onerror = reject img.src = url }) } export async function getImageUrl(taskId: string, imagePath: string) { const [{ file_path, url }] = await getShortUrl(taskId, imagePath) as any return url || file_path } export async function manualPersistence(taskId: string, store: DominoStore) { const state = store.getState() const persistentElements: Record = {} const persistentOrder: string[] = [] Object.values(state.elements).forEach(el => { if (el.type !== 'placeholder') { persistentElements[el.id] = el } }) state.elementOrder.forEach(id => { if (state.elements[id] && state.elements[id].type !== 'placeholder') { persistentOrder.push(id) } }) await saveDomiBoardContent({ task_id: taskId, data: { elements: persistentElements, elementOrder: persistentOrder, createdAt: state.metadata?.createdAt || Date.now(), updatedAt: Date.now(), }, }) } export function getImgElement(id: string) { return getDominoDOM(id)?.querySelector('img') } /** * 从 Performance API 获取资源大小(零开销,但需要同域或配置了 Timing-Allow-Origin) */ export function getImageFileSizeFromPerformance(url: string) { try { // 确保使用绝对路径进行查找,因为 Performance entries 存的是绝对 URL const absoluteUrl = new URL(url, window.location.origin).href const entries = performance.getEntriesByName( absoluteUrl, 'resource', ) as PerformanceResourceTiming[] if (entries.length > 0) { const entry = entries[entries.length - 1] // 优先取 encodedBodySize (压缩后大小),兜底取 transferSize return entry.encodedBodySize || entry.transferSize || 0 } } catch (e) { console.warn('Failed to get file size via performance API', e) } return 0 } /** * 获取图片文件比特大小(回归稳健的 fetch 方案) */ export async function getImageFileSizeFromUrl(url: string) { try { const response = await fetch(url) const blob = await response.blob() return blob.size } catch (e) { console.warn('Failed to get file size via fetch', e) } return 0 } export async function getImageFileSize(element: ImageElement) { let fileSize = (element.data?.size as number) || 0 if (!fileSize && element.src) { // 1. 尝试从 Performance API 获取(最快,零额外网络开销) fileSize = getImageFileSizeFromPerformance(element.src) // 2. 兜底尝试 fetch if (!fileSize) { fileSize = await getImageFileSizeFromUrl(element.src) } } return fileSize } export function isValidImageType(type: string) { return [ 'image/png', 'image/jpeg', 'image/webp', 'image/heic', 'image/avif', ].includes(type) } export function isValidImageFormat(fileName: string) { const name = fileName.split('.').pop() ?? '' return /(png|jpe?g|webp|avif)/.test(name.toLowerCase()) } export function isValidImageFileSize(fileSize: number) { return fileSize > 0 && fileSize < 1024 * 1024 * 10 } export function isValidImageSize(width: number, height: number) { const [min, max] = [width, height].sort((a, b) => a - b) return min > 0 && min < 2160 && max > 0 && max < 3840 } export async function copyImageToClipboard( elementId: string, ): Promise { const img = getImgElement(elementId) if (!img) { console.warn('Image element not found') return false } let imageBlob: Blob | null = null try { const canvas = document.createElement('canvas') canvas.width = img.naturalWidth canvas.height = img.naturalHeight const ctx = canvas.getContext('2d') if (ctx) { ctx.drawImage(img, 0, 0) imageBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'), ) } } catch (e) { console.error(e) } const absoluteSrc = new URL(img.src, window.location.href).href if (!imageBlob) { try { const resp = await fetch(absoluteSrc) const fetchedBlob = await resp.blob() if (fetchedBlob.type === 'image/png') { imageBlob = fetchedBlob } else if (fetchedBlob.type.startsWith('image/')) { // 强制转换其它格式为 PNG 以满足 Clipboard API 限制 const bitmap = await createImageBitmap(fetchedBlob) const canvas = document.createElement('canvas') canvas.width = bitmap.width canvas.height = bitmap.height canvas.getContext('2d')?.drawImage(bitmap, 0, 0) imageBlob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'), ) } } catch (e) { console.error(e) } } if (!imageBlob) { try { imageBlob = await fetch(absoluteSrc).then(res => res.blob()) } catch (e) { console.error(e) } } const htmlDoc = ` `.trim() try { const dataItems: Record = { 'text/html': new Blob([htmlDoc], { type: 'text/html' }), 'text/plain': new Blob([absoluteSrc], { type: 'text/plain' }), } if (imageBlob && imageBlob.type === 'image/png') { dataItems['image/png'] = imageBlob } await navigator.clipboard.write([new ClipboardItem(dataItems)]) return true } catch (err) { console.warn('Modern clipboard write failed, using command fallback.', err) return false } } /** * 文本元素剪贴板数据结构 */ export interface TextElementClipboardData { type: 'domino-text-element' content: string fontSize?: number fontWeight?: string | number color?: string fontFamily?: string lineHeight?: number textAlign?: 'left' | 'center' | 'right' fontStyle?: 'normal' | 'italic' width?: number height?: number } /** * 复制文本元素到剪贴板(包含完整样式信息) */ export async function copyTextElementToClipboard(textElement: { content: string fontSize?: number fontWeight?: string | number color?: string fontFamily?: string lineHeight?: number textAlign?: 'left' | 'center' | 'right' fontStyle?: 'normal' | 'italic' width?: number height?: number }): Promise { try { const textElementData = JSON.stringify({ type: 'domino-text-element', content: textElement.content, fontSize: textElement.fontSize, fontWeight: textElement.fontWeight, color: textElement.color, fontFamily: textElement.fontFamily, lineHeight: textElement.lineHeight, textAlign: textElement.textAlign, fontStyle: textElement.fontStyle, width: textElement.width, height: textElement.height, }) // 使用 text/html 作为载体,将数据嵌入到 HTML 注释中 const htmlContent = `${textElement.content}` const clipboardItem = new ClipboardItem({ 'text/html': new Blob([htmlContent], { type: 'text/html' }), 'text/plain': new Blob([textElement.content], { type: 'text/plain' }), }) await navigator.clipboard.write([clipboardItem]) return true } catch (err) { console.warn('Failed to copy text element to clipboard', err) return false } } /** * 从剪贴板解析文本元素数据 * 返回 { type: 'styled', data } 表示带样式的文本元素 * 返回 { type: 'plain', text } 表示纯文本 * 返回 null 表示无有效数据 */ export async function parseTextElementFromClipboard(): Promise< | { type: 'styled'; data: TextElementClipboardData } | { type: 'plain'; text: string } | null > { try { const clipboardItems = await navigator.clipboard.read() for (const item of clipboardItems) { // 优先检查 text/html 中是否有 DOMINO_TEXT_ELEMENT 数据 if (item.types.includes('text/html')) { try { const blob = await item.getType('text/html') const html = await blob.text() // 解析 HTML 注释中的元素数据 const match = html.match(//) if (match) { const textData = JSON.parse(match[1]) as TextElementClipboardData return { type: 'styled', data: textData } } } catch { // 解析失败,继续尝试纯文本 } } // 回退到纯文本 if (item.types.includes('text/plain')) { try { const blob = await item.getType('text/plain') const text = await blob.text() if (text.trim()) { return { type: 'plain', text } } } catch { // 忽略错误 } } } } catch { // 剪贴板访问失败 } return null } /** * 等待 DOM 元素渲染完成 * 比 requestAnimationFrame 更可靠,通过轮询确保元素存在且有尺寸 */ export function waitForElement( id: string, timeout = 500, ): Promise { return new Promise(resolve => { const startTime = Date.now() const check = () => { const el = getDominoDOM(id) as HTMLElement | null if (el && el.getBoundingClientRect().width > 0) { resolve(el) } else if (Date.now() - startTime < timeout) { requestAnimationFrame(check) } else { // 超时也 resolve,避免卡住流程 resolve(el) } } requestAnimationFrame(check) }) }