Files
test1/components/image-editor/utils/helper.ts
2026-03-20 07:33:46 +00:00

486 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, SceneElement> = {}
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<boolean> {
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<Blob | null>(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<Blob | null>(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 = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<img src="${absoluteSrc}" style="max-width: 100%;" />
</body>
</html>
`.trim()
try {
const dataItems: Record<string, Blob> = {
'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<boolean> {
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 = `<!--DOMINO_TEXT_ELEMENT:${textElementData}--><span>${textElement.content}</span>`
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(/<!--DOMINO_TEXT_ELEMENT:(.*?)-->/)
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<HTMLElement | null> {
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)
})
}