初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
export function filename(path: string) {
const fileName = path.split('/').pop()
if (!fileName) return { name: '', extension: '' }
const fileNames = fileName.split('.')
const extension = fileNames.pop()
const name = fileNames.join('.')
return { name, extension }
}

View File

@@ -0,0 +1,485 @@
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)
})
}

View File

@@ -0,0 +1,188 @@
export interface ImageMark {
rect: {
x: number
y: number
width: number
height: number
}
number: number
}
export function loadImage(url: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image()
image.crossOrigin = 'anonymous'
image.onload = () => resolve(image)
image.onerror = reject
image.src = url
})
}
export function createCanvas(width: number, height: number) {
if ('OffscreenCanvas' in window) {
return new OffscreenCanvas(width, height)
}
const canvas = document.createElement('canvas')
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
canvas.width = width
canvas.height = height
return canvas
}
export function blob2File(blob: Blob, fileName: string) {
return new File([blob], fileName, { type: blob.type })
}
export interface ImageMarkParams {
image: HTMLImageElement | HTMLCanvasElement | OffscreenCanvas
marks: ImageMark[]
blobFileType?: string
}
export async function canvas2Blob(
canvas: OffscreenCanvas | HTMLCanvasElement,
fileType?: string,
) {
if ('convertToBlob' in canvas) {
return await canvas.convertToBlob({ type: fileType })
}
return new Promise<Blob | null>(resolve => {
canvas.toBlob(blob => {
if (!blob) return resolve(null)
canvas.remove()
resolve(blob)
}, fileType)
})
}
export function image2Blob(image: HTMLImageElement) {
const canvas = createCanvas(image.width, image.height)
const ctx = canvas.getContext('2d') as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
if (!ctx) return null
ctx.drawImage(image, 0, 0, image.width, image.height)
return canvas2Blob(canvas)
}
export async function markImage(params: ImageMarkParams): Promise<Blob | null> {
const { image, marks, blobFileType: fileType = 'image/png' } = params
try {
const canvas = createCanvas(image.width, image.height)
const ctx = canvas.getContext('2d') as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
if (!ctx) return null
ctx.drawImage(image, 0, 0, image.width, image.height)
marks.forEach(mark => {
const { rect, number: markNumber } = mark
ctx.save()
/**
* 1. black-white dashed border
*/
// 1.1 black border
ctx.save()
ctx.strokeStyle = 'black'
ctx.lineWidth = 4
ctx.lineJoin = 'round'
ctx.beginPath()
const round = 6
if (ctx instanceof OffscreenCanvasRenderingContext2D) {
ctx.roundRect(rect.x, rect.y, rect.width, rect.height, round)
} else {
ctx.rect(rect.x, rect.y, rect.width, rect.height)
}
ctx.stroke()
ctx.closePath()
// 1.2 white dashed border
ctx.strokeStyle = 'white'
ctx.lineWidth = 2
ctx.setLineDash([8, 8])
ctx.lineJoin = 'round'
ctx.beginPath()
if (ctx instanceof OffscreenCanvasRenderingContext2D) {
ctx.roundRect(rect.x, rect.y, rect.width, rect.height, round)
} else {
ctx.rect(rect.x, rect.y, rect.width, rect.height)
}
ctx.stroke()
ctx.closePath()
ctx.restore()
/**
* 2. number mark
*/
// 2.1 circle background
const fontSize = 16
const radius = 16
let offsetX = 0
let offsetY = 0
if (
rect.x + rect.width + radius > image.width ||
rect.y + rect.height + radius > image.height
) {
offsetX = -radius
offsetY = -radius
}
ctx.beginPath()
ctx.arc(
rect.x + rect.width + offsetX,
rect.y + rect.height + offsetY,
radius,
0,
Math.PI * 2,
)
ctx.closePath()
ctx.fillStyle = 'blue'
ctx.fill()
// 2.2 circle border
ctx.strokeStyle = 'black'
ctx.lineWidth = 3
ctx.stroke()
ctx.strokeStyle = 'white'
ctx.lineWidth = 2
ctx.stroke()
// 2.3 number
ctx.font = `${fontSize}px Arial`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = 'white'
ctx.fillText(
markNumber.toString(),
rect.x + rect.width + offsetX,
rect.y + rect.height + offsetY + 2,
)
ctx.restore()
})
if ('convertToBlob' in canvas) {
return await (canvas as OffscreenCanvas).convertToBlob({
type: fileType,
quality: 0.8,
})
}
return new Promise<Blob | null>(resolve => {
;(canvas as HTMLCanvasElement).toBlob(blob => {
if (!blob) return resolve(null)
;(canvas as HTMLCanvasElement).remove()
resolve(blob)
}, fileType)
})
} catch (error) {
console.error('Failed to mark image:', error)
return null
}
}

View File

@@ -0,0 +1,21 @@
import { getSTSToken } from '@/package/apis/oss'
import { createCustomOSSUploader } from '@bty/uploader'
export async function uploadFile(
file: File,
filePath: string,
headers?: Record<string, string>,
) {
const uploader = createCustomOSSUploader(getSTSToken)
return await uploader.multipartUpload({
file,
filePath,
options: {
headers,
onProgress: (progress: number) => {
console.log(progress)
},
},
})
}