记录--Vue3 + Fabricjs 定制国庆专属头像
这里给大家分享我在网上总结出来的一些知识,希望对大家有所帮助
生在国旗下,长在春风里!国庆将至,采黎为大家带来 定制头像2.0(国庆头像),让我们用代码的形式为祖国庆生!欢迎大家点赞收藏加关注哦
前言
想看效果或者想定制春节头像的小伙伴请直奔 效果区域;
想一睹定制头像2.0小工具的原理及实现思路请耐心阅读,本文代码片段较多~
在线定制
🚀🚀🚀 定制头像入口, 体验地址 🚀🚀🚀
🚀🚀🚀 github项目地址(欢迎⭐) 🚀🚀🚀
喜欢这个小工具的话,动动小手点个star⭐哦,谢谢!
关于迭代
继 定制兔年春节头像
上线后,很多小伙伴体验后第一时间就给了建议、反馈;在大家的帮助下,工具也在不断的完善;比如导出图片不够清晰、不能设置透明度等等,迭代到1.4.0后,已经可以保证正常的使用了,这里采黎给大家说声谢谢!
由于当时聚焦在兔年、春节头像上,工具风格单一,功能还不够完善,内部逻辑有点大材小用等等,于是便有了大版本的定制头像2.0迭代。
更新内容
仓库名称
- 由 custom-rabbitImage 改为 custom-avatar
页面
- 重构页面整体风格,调整为通用型风格
- 兼容pc、移动端
- 移动端头像墙采用瀑布流
画布相关
- 用户上传的原图做短边适配,保证不变形
- 优化元素控件效果,增加删除控件
- 优化绘制逻辑,减少无用运算。
新增功能
- 增加多主题选项(中秋节、国庆节、春节等,其他传统节日敬请期待)
- 增加贴纸效果,可多选、可删除
- 增加快速切换头像框功能
- 增加通知功能(xx用户在3分钟前定制了国庆头像)
- 增加分享海报功能
- 增加头像墙功能,用户可预览他人定制的头像
修复已知问题
- 修复qq浏览器无法选择文件
- 修复微信浏览器无法保存图片
项目架构
1 | vue3 | vite | ts | less | Elemenu UI | eslint | stylelint | husky | lint-staged | commitlint |
所需素材
头像框、贴纸正在设计中,会一点一点补起来。
中秋主题
国庆主题
春节主题
思路
基本思路不变,定制兔年春节头像中已经讲过,这里就不再赘述了。
画布交互逻辑优化
这是第一版的逻辑梳理
考虑到定制头像工具图层不会过多,功能不会太复杂,于是 在新版中做了如下优化
- 删除绘制多个图层逻辑(监听图层列表变化,进而绘制图层)
- 绘制头像框改为主动调用,减少无用调用频次;
- 绘制贴纸为主动调用,可绘制多个
- 删除画布操作同步逻辑(不需要回显数据到页面,也不用二次绘制,故删除)
做完上述优化后,代码量明显下来了;只怪当时没有过多的思考,就将其他项目的实现方式生搬硬套了。
代码实现
画布
- 初始化画布及控件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | const init = () => { /* 初始化控件 */ initFabricControl() /* 初始化画布 */ Canvas = initCanvas(CanvasId.value, canvasSize, false ) // 元素缩放事件 Canvas. on ( 'object:scaling' , canvasMouseScaling) } /* 初始化控件 */ const initFabricControl = () => { fabric.Object.prototype. set (control) // 设置缩放摇杆偏移 fabric.Object.prototype.controls.mtr.offsetY = control.mtrOffsetY // 隐藏不需要的控件 hiddenControl.map((name: string ) => (fabric.Object.prototype.controls[name].visible = false )) /* 添加删除控件 */ const delImgElement = document.createElement( 'img' ) delImgElement.src = new URL( './icons/delete.png' , import.meta.url).href const size = 52 const deleteControlHandel = (e, transform:any) => { const target = transform.target const canvas = target.canvas canvas.remove(target).renderAll() } const renderDeleteIcon = (ctx:any, left:any, top:any, styleOverride:any, fabricObject:any) => { ctx.save() ctx.translate(left, top) ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle)) ctx.drawImage(delImgElement, -size / 2, -size / 2, size, size) ctx.restore() } fabric.Object.prototype.controls.deleteControl = new fabric.Control({ x: 0.5, y: -0.5, cornerSize: size, offsetY: -48, offsetX: 48, cursorStyle: 'pointer' , // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore mouseUpHandler: deleteControlHandel, render: renderDeleteIcon }) } |
- 监听原图(用户上传的头像)改变,并进行短边适配
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | /* 更改原图 */ watch(() => props.bg, async (val) => (await drawBackground(Canvas, val))) /** * @function drawBackground 绘制背景 * @param { Object } Canvas 画布实例 * @param { String } bgUrl 用户上传得原图片链接 */ export const drawBackground = async (Canvas, bgUrl: string ) => { return new Promise((resolve: any) => { if (!bgUrl) return resolve() fabric.Image.fromURL(bgUrl, (img: any) => { img. set ({ left: Canvas.width / 2, top: Canvas.height / 2, originX: 'center' , originY: 'center' }) /* 短边适配 */ img.width > img.height ? img.scaleToHeight(Canvas.height, true ) : img.scaleToWidth(Canvas.width, true ) Canvas.setBackgroundImage(img, Canvas.renderAll.bind(Canvas)) resolve() }, { crossOrigin: 'Anonymous' }) }) } |
- 绘制头像框,并隐藏删除按钮控件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | const frameName = 'frame' /** * @function addFrame 添加头像框图层 * @param { String } url 头像框链接 */ const addFrame = async (url = '' ) => { if (!url) return const frameLayer: any = await drawImg(`${ url }!frame`) frameLayer. set ({ left: Canvas.width / 2, top: Canvas.height / 2 }) /* 隐藏删除按钮 */ frameLayer.setControlVisible( 'deleteControl' , false ) frameLayer.scaleToWidth(Canvas.width, true ) frameLayer.name = frameName addOrReplaceLayer(Canvas, frameLayer) } |
- 设置头像框透明度
1 2 3 4 5 6 7 8 9 10 11 12 | /** * @function setFrameOpacity 设置头像框透明度 * @param { Number } opacity 透明度 */ const setFrameOpacity = (opacity = 1) => { const frameLayer: any = findCanvasItem(Canvas, frameName)[1] || '' if (!frameLayer) return frameLayer. set ({ opacity }) Canvas.renderAll() } |
- 绘制贴纸
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | /** * @function addMark 添加贴纸 * @param { String } url 贴纸链接 */ const addMark = async (url) => { if (!url) return const markLayer: any = await drawImg(url) markLayer. set ({ left: Canvas.width / 2, top: Canvas.height / 2 }) markLayer.width > markLayer.height ? markLayer.scaleToHeight(200, true ) : markLayer.scaleToWidth(200, true ) markLayer.name = `mark-${ createUuid() }` addOrReplaceLayer(Canvas, markLayer) } |
- 保存图片,导出base64
1 2 3 4 5 6 7 8 9 10 11 12 13 | /** * @function save 保存效果图 * @return { String } result base64 保存/预览时返回 */ const save = async (): Promise< string > => { return Canvas.toDataURL({ format: 'png' , left: 0, top: 0, width: Canvas.width, height: Canvas.height }) } |
现在代码明朗了很多,犹如柳暗花明。
页面交互
- 用户上传图片,生成本地短链,然后绘制原头像,并默认绘制第一个头像框。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | const uploadFile = async (e: any) => { 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 url = getCreatedUrl(file) ?? '' /* 用户初次上传头像默认选中第一个头像框 */ if (!originAvatarUrl.value) { originAvatarUrl.value = url selectFrame(0) } else { originAvatarUrl.value = url } (document.getElementById( 'uploadImg' ) as HTMLInputElement).value = '' } |
- 用户点击头像框或点击快速切换按钮,绘制头像框
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /* 快速切换头像框 */ const changeFrame = (isNext) => { if (!originAvatarUrl.value) return ElMessage.warning( '请先上传头像!' ) const frameList = picList[styleIndex.value].frameList if (isNext) { (selectFrameIndex.value === frameList.length - 1) ? selectFrameIndex.value = 0 : (selectFrameIndex.value as number)++ } else { (selectFrameIndex.value === 0) ? selectFrameIndex.value = frameList.length - 1 : (selectFrameIndex.value as number)-- } selectFrame(selectFrameIndex.value as number) } /* 绘制头像框-调用画布绘制函数 */ const selectFrame = (index: number) => { if (!originAvatarUrl.value) return ElMessage.warning( '请先上传头像!' ) opacity.value = 1 selectFrameIndex.value = index frameUrl.value = picList[styleIndex.value].frameList[index] DrawRef.value.addFrame(frameUrl.value) } |
- 设置头像框透明度
1 2 | const opacity = ref <number>(1) const opacityChange = (num: number) => DrawRef.value.setFrameOpacity(num) |
- 点击贴纸,绘制贴纸
1 2 3 4 5 6 | const selectMark = (index: number) => { if (!originAvatarUrl.value) return ElMessage.warning( '请先上传头像!' ) const markUrl = picList[styleIndex.value].markList[index] DrawRef.value.addMark(markUrl) } |
页面的交互逻辑相对简单,一步一步走就ok。
滚动通知动画效果
这里使用vue的过渡动画,模拟了滚动的效果, 本质就是key变了后,会触发弹入弹出效果。
1 2 3 4 5 6 7 8 9 10 11 12 | <transition name= "notice" mode= "out-in" > <div v- if = "avatarList && avatarList.length" class = "notice" :key= "avatarList[noticeIndex].last_modified" > <p> <span style= "color: #409eff;" >游客{{ (avatarList[noticeIndex].last_modified + '' ).slice(-5) }} </span> <span style= "padding-left: 2px;" >{{ calcOverTime(avatarList[noticeIndex].last_modified) }}前</span> <span style= "padding-right: 2px;" >制作了</span> <span style= "color: #f56c6c;" >{{ styleEnums[avatarList[noticeIndex].id] }}头像 </span> <span style= "padding-left: 4px;" ></span> </p> <img :src= "avatarList[noticeIndex].url" alt= "" > </div> </transition> |
海报功能
这个用html2canvas库就好了,用正常的css属性,他都可以实现。
1 2 3 4 | <!-- 生成海报 --> <div id= "poster" class = "poster" > <!-- 内容省略 --> </div> |
1 2 3 4 5 6 7 8 9 10 | /* 注意图片跨域 */ await nextTick(() => { /* 生成海报 */ const posterDom = document.getElementById( 'poster' ) as HTMLElement html2canvas(posterDom, { useCORS: true }).then((canvas) => { shareUrl.value = canvas.toDataURL( 'image/png' ) shareShow.value = true loading.value = false }) }) |
移动端瀑布流实现
pc和移动端都是grid布局,我们给移动端的行列份数随机,pc端强制设为1,保证行、列所占的份数一致就好(定制头像导出都是正方形的)
grid-auto-flow: dense; 这个样式是关键,
1 2 3 4 5 6 | <div class = "wall" > <div class = "wall-list" > <el-image v- for = "(url, index) in avatarPageUrlList" :key= "url" :src= "url" :style= "{ gridColumn: `span ${ avatarList[index].span}`, gridRow: `span ${ avatarList[index].span }` }" /> </div> </div> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | .wall { .wall-list { display: grid; gap: 8px; grid-template-columns: repeat(8, minmax(0, 1fr)); grid-auto-flow: dense; } .wall-more { padding-top: 16px; text-align: center; } } /* pc端不使用瀑布流,强覆盖行列份数 */ @media only screen and (min-width: 769px) { .wall { .wall-list { > div { grid-row: span 1 !important; grid-column: span 1 !important; } } } } |
到这里,基本核心、细节的点都实现了;若想知道更多代码设计、开发思路,请移步github,代码已开源。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· DeepSeek 开源周回顾「GitHub 热点速览」
2022-09-27 记录--对于$off,Exclude 和 Extract的一点理解