初始化模版工程

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,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 }
}