266 lines
6.8 KiB
TypeScript
266 lines
6.8 KiB
TypeScript
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 }
|
||
}
|