初始化模版工程
This commit is contained in:
8
components/image-editor/utils/filename.ts
Normal file
8
components/image-editor/utils/filename.ts
Normal 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 }
|
||||
}
|
||||
485
components/image-editor/utils/helper.ts
Normal file
485
components/image-editor/utils/helper.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
188
components/image-editor/utils/mark-image.ts
Normal file
188
components/image-editor/utils/mark-image.ts
Normal 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
|
||||
}
|
||||
}
|
||||
21
components/image-editor/utils/upload.ts
Normal file
21
components/image-editor/utils/upload.ts
Normal 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)
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user