Files
test1/components/image-editor/hooks/use-download-image-group.ts
2026-03-20 07:33:46 +00:00

266 lines
6.8 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 type {
SceneElement,
ImageElement,
TextElement,
} from '../components/canvas'
/**
* 直接加载图片(不做跨域处理)
*/
function loadImageDirectly(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Failed to load image: ${src}`))
img.src = src
})
}
/**
* 加载图片(通过 fetch 获取 blob避免跨域污染 canvas
* 对于跨域图片,先 fetch 为 blob再创建 object URL
*/
async function loadImage(src: string): Promise<HTMLImageElement> {
// 检查是否是 data URL 或 blob URL这些不需要特殊处理
if (src.startsWith('data:') || src.startsWith('blob:')) {
return loadImageDirectly(src)
}
// 检查是否是同源
try {
const url = new URL(src, window.location.origin)
if (url.origin === window.location.origin) {
// 同源图片直接加载
return loadImageDirectly(src)
}
} catch {
// URL 解析失败,尝试直接加载
return loadImageDirectly(src)
}
// 跨域图片:通过 fetch 获取 blob
try {
const response = await fetch(src, { mode: 'cors' })
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`)
}
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
try {
const img = await loadImageDirectly(blobUrl)
// 注意:这里不立即 revoke因为 canvas 可能还需要使用
// 在 exportFrameAsCanvas 完成后会释放
return img
} catch (err) {
URL.revokeObjectURL(blobUrl)
throw err
}
} catch {
// fetch 失败,回退到直接加载(可能仍会污染 canvas
console.warn(
`[Export] Failed to fetch image via blob, falling back to direct load: ${src}`,
)
return loadImageDirectly(src)
}
}
/**
* 绘制单行文字
*/
function drawTextLine(
ctx: CanvasRenderingContext2D,
line: string,
boxX: number,
y: number,
maxWidth: number,
textAlign: string,
) {
let x = boxX
if (textAlign === 'center') {
x = boxX + maxWidth / 2
ctx.textAlign = 'center'
} else if (textAlign === 'right') {
x = boxX + maxWidth
ctx.textAlign = 'right'
} else {
ctx.textAlign = 'left'
}
ctx.fillText(line, x, y)
}
/**
* 绘制文本元素并处理换行
*/
function drawTextElement(
ctx: CanvasRenderingContext2D,
element: TextElement,
boxX: number,
boxY: number,
) {
const {
content,
fontSize = 32,
fontFamily = 'Inter, sans-serif',
color = '#000000',
textAlign = 'left',
fontWeight = 'normal',
fontStyle = 'normal',
width: maxWidth,
} = element
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
ctx.fillStyle = color
ctx.textBaseline = 'top'
const lineHeight = fontSize * 1.2
const paragraphs = (content || '').split('\n')
let currentY = boxY
for (const paragraph of paragraphs) {
if (paragraph === '') {
currentY += lineHeight
continue
}
// 字符级换行,匹配 break-words 逻辑
const chars = paragraph.split('')
let currentLine = ''
for (let n = 0; n < chars.length; n++) {
const testLine = currentLine + chars[n]
const metrics = ctx.measureText(testLine)
const testWidth = metrics.width
if (testWidth > maxWidth && n > 0 && currentLine !== '') {
drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign)
currentLine = chars[n]
currentY += lineHeight
} else {
currentLine = testLine
}
}
drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign)
currentY += lineHeight
}
}
export function useDownLoadImageGroup(
selectionBounds: {
left: number
top: number
width: number
height: number
} | null,
elements: Array<SceneElement>,
) {
const { left = 0, top = 0, width = 0, height = 0 } = selectionBounds ?? {}
const scale = 2 // 放大倍数,提升图片质量
const drawCanvas = async () => {
// 创建 canvas
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = width * scale
canvas.height = height * scale
ctx.scale(scale, scale)
// 填充白色背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
// 渲染子元素
for (const element of elements) {
if (!element) continue
const { x, y, width: elWidth, height: elHeight, rotation = 0 } = element
const boxX = x - left
const boxY = y - top
ctx.save()
// 旋转处理
if (rotation !== 0) {
ctx.translate(boxX + elWidth / 2, boxY + elHeight / 2)
ctx.rotate((rotation * Math.PI) / 180)
ctx.translate(-(boxX + elWidth / 2), -(boxY + elHeight / 2))
}
if (element.type === 'image') {
const img = await loadImage((element as ImageElement).src)
ctx.drawImage(img, boxX, boxY, elWidth, elHeight)
} else if (element.type === 'text') {
drawTextElement(ctx, element as TextElement, boxX, boxY)
}
ctx.restore()
}
return canvas
}
const downloadCompositeImage = async () => {
const canvas = await drawCanvas()
if (!canvas) return
// 生成图片并下载
canvas.toBlob(
blob => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `composite-${Date.now()}.jpg`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
'image/jpeg',
0.95,
)
}
const downloadImage = async (
src: string,
fileName: string,
useCanvas = false,
) => {
if (useCanvas) {
const img = await loadImage(src)
// 生成图片并下载
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(img, 0, 0)
canvas.toBlob(
blob => {
if (!blob) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName || 'image.jpeg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
},
'image/jpeg',
0.95,
)
} else {
// 直接使用a标签下载图片
const link = document.createElement('a')
link.href = src
link.download = fileName || 'image.jpeg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
return { downloadCompositeImage, downloadImage }
}