3分钟定制中秋贺卡 🌕 送祝福·庆团圆·寄相思
露从今夜白,月是故乡明!中秋将至,团圆之时近。中秋将至,采黎为大家带来 定制中秋贺卡。值此佳节,让我们通过贺卡传递温馨祝福,庆祝团圆,寄托思念。欢迎大家一键三连~🙏🙏🙏
效果
好奇的小伙伴可以先看效果,项目链接呈上~ 🤣
github项目地址(欢迎⭐) 代码已开源,如果喜欢这个项目请动动小手点个star⭐,谢谢!
项目架构
typescript
vue3 | ts | less | Elemenu UI | fabricjs
项目素材
页面素材
海报所需素材
贺卡主题
1. 祝福主题
- 月光所至,万事如意。祝你,祝我,祝我们!
2. 团聚主题
- 一家人团聚在中秋佳节,灯火可亲~
3. 思念主题
- 在外漂泊、异国他乡的游子,寄托月是故乡明的思乡情
- 相隔万里的恋人,诉说两处相思同望月,此刻也算共团圆!
思路
- 以祝福、团圆、思念为载体,预置三个贺卡主题。
- 用户只需上传头像,修改文案,简单调整位置即可快速定制出中秋贺卡。
- 支持预览、保存。
- 支持生成海报,分享给朋友。
- 支持中秋贺卡集功能,用户可观看他人定制的贺卡。
页面布局交互
- 贺卡画布居中
- 页面用素材点缀,添加动画过渡等效果。
- 支持快速切换主主题功能(左右箭头)。
贺卡实现原理
- 固定贺卡画布固定比例。
- 背景图片短边适配,且不可操作。
- 绘制圆形头像,支持用户重新上传头像。
- 绘制文本(横排、竖排文本),支持用户更改文案。
- 导出贺卡图片,生成贺卡、海报等。
代码实现
创建初始画布
typescript
/**
* @function initCanvas 初始化画布
* @param { String } inkId 画布dom id
* @param { Object } size 画布大小 { width, height }
* @param { Boolean } isStatic 是否静态画布
* @return { Object } Canvas 返回画布实例对象
*/
export const initCanvas = (inkId: string, size: CanvasSizeType, isStatic= false) => {
const Canvas: Canvas | StaticCanvas = new fabric[isStatic ? 'StaticCanvas' : 'Canvas'](inkId, size)
// 关闭点击后图层被置顶
Canvas.preserveObjectStacking = true
// 关闭多选
Canvas.selection = false
// 设置中心缩放
Canvas.centeredScaling = true
return Canvas
}
背景图层
typescript
/**
* @function drawBackground 绘制背景
* @param { Object } Canvas 画布实例
* @param { Object } designInfo 背景信息 背景图片链接、url等
*/
export const drawBackground = async (Canvas, designInfo: DesignInfo) => {
return new Promise((resolve: any) => {
if (!designInfo.bg) return resolve()
fabric.Image.fromURL(designInfo.bg, (img: any) => {
img.set({
left: Canvas.width / 2,
top: Canvas.height / 2,
originX: 'center',
originY: 'center'
})
// 背景短边适配
const imgRatio = img.width / img.height
const canvasRatio = 0.5
canvasRatio >= imgRatio ? img.scaleToWidth(Canvas.width, true) : img.scaleToHeight(Canvas.height, true)
Canvas.setBackgroundImage(img, Canvas.renderAll.bind(Canvas))
resolve()
}, { crossOrigin: 'Anonymous' })
})
}
头像图层
头像头层稍微复杂一点,一步步来就好。
1. 绘制图片图层
typescript
/**
* @function drawImg 绘制图片
* @param { String } url 图片链接
* @return { Object } img 返回图片画布对象
*/
export const drawImg = (url: string) => {
return new Promise(async (resolve: any) => {
fabric.Image.fromURL(url, (img) => resolve(img), { crossOrigin: 'Anonymous' })
})
}
const { name, url, left, top, w, angle } = layer
const imgLayer: any = await drawImg(url)
imgLayer.set({ left, top, angle })
imgLayer.scaleToWidth(w, true)
imgLayer.name = name
addOrReplaceLayer(Canvas, imgLayer)
2. 自定义右上角上传图片控件
typescript
/* 绘制自定义上传控件 */
const uploadLayer: any = await drawImg(new URL('../../icons/upload-img.png', import.meta.url).href)
uploadLayer.scaleToHeight(40, true)
const uploadImgDom = document.getElementById('uploadImg') as HTMLInputElement
const customControl = new fabric.Control({
x: 0.5,
y: -0.5,
offsetY: 0, // 垂直偏移以使图标居中
cursorStyle: 'pointer', // 鼠标悬停样式
mouseUpHandler: () => uploadImgDom.click(),
render: function (ctx, left, top) {
uploadLayer.set({ left, top: top })
uploadLayer.render(ctx)
}
})
imgLayer.setControlsVisibility({ tr: false })
// 将自定义控制控件添加到元素1
imgLayer.setControlVisible('mtr', true) // 显示元素1的默认控制控件
imgLayer.controls.customControl = customControl // 添加自定义控制控件
3. 监听控件点击事件触发file文件上传
typescript
/* 图片上传逻辑 */
uploadImgDom.addEventListener('change', async (e) => {
const url = await uploadImg(e) as string
uploadImgDom.value = ''
const imgLayerWidth = imgLayer.getScaledWidth()
/* 仅更新图片源,其他参数保留 */
imgLayer.setSrc(url, (newImgLayer) => {
const ratio = imgLayerWidth / newImgLayer.width
newImgLayer.set({
left: imgLayer.left,
top: imgLayer.top,
angle: imgLayer.angle,
scaleX: ratio,
scaleY: ratio
})
// 刷新 Canvas 以显示更新后的图片元素
Canvas.renderAll()
})
})
3.1 图片上传后裁剪为圆形(短边适配)
因为圆形图片绘制的问题,只能先对图片进行裁剪,下面 项目难点1 中会讲到。
typescript
const uploadImg = async (e) => {
return new Promise((resolve, reject) => {
if (!e.target.files || !e.target.files.length) return ElMessage.warning('上传失败!')
const file = e.target.files[0]
if (!file.type.includes('image')) return ElMessage.warning('请上传正确的图片格式!')
const canvas: any = document.getElementById('circleCanvas')
const ctx = canvas.getContext('2d')
const imgUrl = getCreatedUrl(file) ?? ''
const image = new Image()
image.src = imgUrl
image.onload = () => {
const diameter = Math.min(image.width, image.height) // 获取最短边作为直径
canvas.width = diameter
canvas.height = diameter
const centerX = diameter / 2
const centerY = diameter / 2
// 创建一个圆形路径
ctx.beginPath()
ctx.arc(centerX, centerY, diameter / 2, 0, Math.PI * 2, false)
ctx.closePath()
ctx.clip()
// 计算图片的偏移,使其居中
const offsetX = (image.width - diameter) / 2
const offsetY = (image.height - diameter) / 2
// 将图片绘制到圆形区域内,不变形
ctx.drawImage(image, offsetX, offsetY, diameter, diameter, 0, 0, diameter, diameter)
return resolve(canvas.toDataURL('image/png'))
}
image.onerror = () => reject('')
})
}
横排文本
typescript
/**
* @function drawTextLayer 绘制文本图层
* @param { Object } Canvas 画布实例对象
* @param { Object } layer 图层对象
* @return { Object } layer 返回文本 图层对象
*/
export const drawTextLayer = (Canvas: any, layer: any) => {
return new Promise(async (resolve: any) => {
const { name, left, top, text, url, fontSize, fontColor, fontWeight } = layer
/* 注册字体 */
if (url && !globalThis.fontObj[url]) {
const font = new window.FontFace(url, `url(${url})`)
document.fonts.add(font)
const res = await font.load().catch(() => ({}))
if (res && res.status === 'loaded') globalThis.fontObj[url] = url
}
const textStyle = {
left,
top,
fontSize,
fontWeight,
fontFamily: url,
fill: fontColor,
lineHeight: 1,
cursorColor: fontColor,
editingBorderColo: '#fff'
}
const textSprite = new fabric.Textbox(text, textStyle)
textSprite.name = name
// 保留死角缩放,去除其他按钮控件
textSprite.setControlsVisibility({ ml: false, mr: false, mt: false, mb: false })
addOrReplaceLayer(Canvas, textSprite)
return resolve(textSprite)
})
}
纵排文本
竖排文字跟横排文字类似,在此基础上固定了文本框的宽度
typescript
/* 补充了这两个属性 */
const textStyle = {
width: fontSize,
splitByGrapheme: true
}
封装画布组件(使用fabric.js),其具有绘制、调整、预览、导出图片等功能;在页面引入组件,通过props传递参数,组件通信等实现定制兔年春节头像工具的功能。
项目难点
1. 绘制圆形头像
最初用fabricjs 中的 clipPath 做的, 但是在移动端裁剪失效,图片无法绘制。
typescript
const { name, url, left, top, w, angle } = layer
/* 绘制圆形图片 */
if (!url) return resolve()
const imgLayer: any = await drawImg(url)
imgLayer.set({
left,
top,
angle,
clipPath: new fabric.Circle({
radius: Math.min(imgLayer.height, imgLayer.width) / 2
})
})
试了几种方式, 还是以失败告终。随后便想到了------将图片先裁剪为圆形再进行绘制。 试了后果然可以,这个问题就解决了
2. 绘制自定义上传控件
自定义上传控件,这不算是个难点,但是知识点很细。官方文档 有相应的案例,还有一个细小的知识点是 设置控件的显示隐藏
typescript
/* 自定义控件在代码实现中有贴出 */
/* 设置某一元素右上角的控件隐藏 */
imgLayer.setControlsVisibility({ tr: false })
3. 使用自定义字体
自定义字体就跟正常的api一样去使用就OK,有个细节在于处理自定义字体的异常捕获
,它会导致文本绘制失败。 这里我还做了字体加载的性能优化。
typescript
/* 初始化字体缓存 */
if (!('fontObj' in globalThis)) globalThis.fontObj = {}
/* 注册字体 */
/* 若字体缓存池中未加载字体,则开始加载,否则跳过 */
if (url && !globalThis.fontObj[url]) {
const font = new window.FontFace(url, `url(${url})`)
document.fonts.add(font)
/* 这里的catch捕获异常必须加上,否则文本绘制失败 */
/* 加上异常捕获后, 即使字体未加载成功,也会使用默认字体绘制 */
const res = await font.load().catch(() => ({}))
if (res && res.status === 'loaded') globalThis.fontObj[url] = url
}
3. 移动端输入文字时页面跳跃
这个问题有两个原因
- 画布过大,且不在页面中心区域;我的画布600 * 1080,然后进行缩放定位。
- 文本框编辑时,fabricjs默认处理会将当前文本框置于画布的中心区域,在body下加一个
textarea
,根据画布大小和页面进行计算;
html
<textarea autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-fabric-hiddentextarea="" wrap="off" style="position: absolute; top: 1152.77px; left: 849.5px; z-index: -999; opacity: 0; width: 1px; height: 1px; font-size: 1px; padding-top: 42px;"></textarea>
画布我已经做过兼容处理,一直处于页面的中心区域, 所以我对其textarea
样式做了覆盖。
css
/* 解决fabric.js 文本框输入时页面跳跃 */
textarea {
top: 0 !important;
left: 0 !important;
padding-top: 0 !important;
}
4. 移动端图片保存及分享
这是个老生常谈的问题了,在 定制春节头像 项目上就栽了跟头;pc端不存在这个问题,主要集中在了移动端;微信内置浏览器是不允许下载文件的,所以只能曲线救国;
解决方案:将要保存或分享的图片展示在页面上, 引导用户长按保存、分享。
开源
2023年已过大半,开源项目大大小小也有十来个了。但这个开源项目是不一样的,构思、开发、设计都是我一个人独立完成,虽然从设计、产品层面来说,她不是那么的完美,但却是极其有意义的!
从构思到完成,用了不到五天的时间;熬夜写页面,优化交互,好像有使不完的劲儿。这种感觉无疑是美妙的,难于言表~
意见&建议
关于中秋贺卡 ,有任何意见或建议可评论、私信或提交 github issues ,鸣谢!
招募
细心的小伙伴可能发现了,我喜欢做一些有创意的、好玩的、有趣的项目;如果你也喜欢,可以点个关注哦。不管是设计、前后端开发,我们都可以相聚于此,沟通交流,绽放创作之花!
余音
月光所照皆是故乡,双脚所踏皆是生活。 制贺卡,送祝福,再会!