3分钟定制中秋贺卡 🌕 送祝福·庆团圆·寄相思
露从今夜白,月是故乡明!中秋将至,团圆之时近。中秋将至,采黎为大家带来定制中秋贺卡。值此佳节,让我们通过贺卡传递温馨祝福,庆祝团圆,寄托思念。欢迎大家一键三连~🙏🙏🙏
效果
好奇的小伙伴可以先看效果,项目链接呈上~ 🤣
效果直达车
效果直达车(github备用链接)
github项目地址(欢迎⭐)
代码已开源,如果喜欢这个项目请动动小手点个star⭐,谢谢!
使用教程
点击头像,点击右上角即可上传头像。
双击文本,即可修改文本内容。
头像和文本皆可移动位置、缩放、旋转等。
项目架构
vue3 | ts | less | Elemenu UI | fabricjs
项目素材
页面素材
海报所需素材
贺卡主题
1.祝福主题
月光所至,万事如意。祝你,祝我,祝我们!2.团聚主题
一家人团聚在中秋佳节,灯火可亲~3.思念主题
在外漂泊、异国他乡的游子,寄托月是故乡明的思乡情相隔万里的恋人,诉说两处相思同望月,此刻也算共团圆!思路
以祝福、团圆、思念为载体,预置三个贺卡主题。用户只需上传头像,修改文案,简单调整位置即可快速定制出中秋贺卡。支持预览、保存。支持生成海报,分享给朋友。支持中秋贺卡集功能,用户可观看他人定制的贺卡。页面布局交互
贺卡画布居中页面用素材点缀,添加动画过渡等效果。支持快速切换主主题功能(左右箭头)。贺卡实现原理
固定贺卡画布固定比例。背景图片短边适配,且不可操作。绘制圆形头像,支持用户重新上传头像。绘制文本(横排、竖排文本),支持用户更改文案。导出贺卡图片,生成贺卡、海报等。代码实现
创建初始画布
/** * @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 = truereturn Canvas }
背景图层
/** * @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. 绘制图片图层
/** * @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. 自定义右上角上传图片控件
/* 绘制自定义上传控件 */ 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文件上传
/* 图片上传逻辑 */ 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中会讲到。
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('') }) }
横排文本
/** * @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) }) }
纵排文本
竖排文字跟横排文字类似,在此基础上固定了文本框的宽度
/* 补充了这两个属性 */const textStyle = {width: fontSize, splitByGrapheme: true }
封装画布组件(使用fabric.js),其具有绘制、调整、预览、导出图片等功能;在页面引入组件,通过props传递参数,组件通信等实现定制兔年春节头像工具的功能。
项目难点
1. 绘制圆形头像
最初用fabricjs 中的 clipPath 做的, 但是在移动端裁剪失效,图片无法绘制。
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. 绘制自定义上传控件
自定义上传控件,这不算是个难点,但是知识点很细。官方文档 有相应的案例,还有一个细小的知识点是设置控件的显示隐藏
/* 自定义控件在代码实现中有贴出 *//* 设置某一元素右上角的控件隐藏 */imgLayer.setControlsVisibility({tr: false })
3. 使用自定义字体
自定义字体就跟正常的api一样去使用就OK,有个细节在于处理自定义字体的异常捕获
,它会导致文本绘制失败。
这里我还做了字体加载的性能优化。
/* 初始化字体缓存 */ 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
,根据画布大小和页面进行计算;
<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
样式做了覆盖。
/* 解决fabric.js 文本框输入时页面跳跃 */ textarea {top: 0 !important; left: 0 !important; padding-top: 0 !important; }
4. 移动端图片保存及分享
这是个老生常谈的问题了,在定制春节头像项目上就栽了跟头;pc端不存在这个问题,主要集中在了移动端;微信内置浏览器是不允许下载文件的,所以只能曲线救国;
解决方案:将要保存或分享的图片展示在页面上, 引导用户长按保存、分享。
开源
已过大半,开源项目大大小小也有十来个了。但这个开源项目是不一样的,构思、开发、设计都是我一个人独立完成,虽然从设计、产品层面来说,她不是那么的完美,但却是极其有意义的!
从构思到完成,用了不到五天的时间;熬夜写页面,优化交互,好像有使不完的劲儿。这种感觉无疑是美妙的,难于言表~
意见&建议
关于中秋贺卡,有任何意见或建议可评论、私信或提交github issues,鸣谢!
招募
细心的小伙伴可能发现了,我喜欢做一些有创意的、好玩的、有趣的项目;如果你也喜欢,可以点个关注哦。不管是设计、前后端开发,我们都可以相聚于此,沟通交流,绽放创作之花!
余音
月光所照皆是故乡,双脚所踏皆是生活。 制贺卡,送祝福,再会!