486 lines
13 KiB
TypeScript
486 lines
13 KiB
TypeScript
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)
|
||
})
|
||
}
|