初始化模版工程
This commit is contained in:
265
components/image-editor/hooks/use-download-image-group.ts
Normal file
265
components/image-editor/hooks/use-download-image-group.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user