如何在浏览器中使用动态光标
1、需求:
网页文件下载慢,想在浏览器中使用动态光标提示正在加载中,让人更加直观更容易耐心等待。
2、深入挖掘
在weindow系统中使用动态光标是通过.ani文件来实现的,而各大浏览器的cursor属性目前都不支持通过url()来使用.ani文件,只支持静态ico、cur文件,火狐浏览器更加是限制大小为32*32。
那么在浏览器中是如何使用动态光标的呢?最简单就是www.npmjs.com中搜索cursor相关的js库,或者自己实现一个
3、自己实现思路
在浏览器中光标只能通过cursor属性来更换光标,且是静态的,但是视频也是由一帧一帧的静态图片组成,如果浏览器中光标通过cursor属性以每秒24帧的速度更换帧图片,那么这不是动态光标是什么?
在浏览器中如何让cursor属性以每秒24帧的速度更换帧图片呢?css中的动画@keyframes就可以实现,且可以通过设置infinite来无限循环。
动态光标实现原理已经了解,那么就是资源的问题,从哪里获取跟鼠标相关的图片帧,在weindow系统中使用动态光标是通过.ani文件来实现的
那么能不能通过读取.ani光标文件,获取其中的图片帧,拿这些图片生成cursor的动画,然后把这个动画加载到body上,那么鼠标在页面就可以是动态光标了
思路已经有了:读取.ani文件=》解析并从中获取图片帧=》处理图片帧生成cursor的动态动画=》加载到dom节点上
4、实现
4-1、读取.ani文件
读取.ani文件最好不要从本地读取,而是把.ani文件上传至服务器,然后通过调用api方式进行读取,因为.ani文件是window系统的动态光标格式,在Linux和苹果等其他系统上并不适用,本地读取是会出现不同系统读取数据不一致的问题
//导入ani路径 putInAni = async ( aniUrl: string, dom: string | HTMLElement = 'body', animationName: string = 'ani', defaultCursor: string = 'auto', width: number = 32, height: number = 32, ) => { // 解析ani数据,从中提取ani结构数据 let { aniData, arrayBuffer, uint8Array } = await this.analysisAni(aniUrl); //从ani结构数据提取图片url数组 let aniUrlList = await this.extractImages( arrayBuffer, uint8Array, aniData, width, height, ); //通过图片url数组生成对应的动画Style let { styleContent, aniClassName } = this.generateAnimation( aniUrlList, animationName, defaultCursor, ); //插入到页面中去 this.insertPage(dom, styleContent, aniClassName); };
4-2、解析.ani文件、从中获取图片帧
解析.ani文件就先需要了解.ani文件格式是怎么样的,这里推荐去看动态光标格式解析 这篇文章
.ani分为'anih', 'rate', 'seq ', 'LIST'等几个区块,'anih'区块是放基本信息的,'seq '区块是放图片帧播放顺序的,'rate'区块是放图片帧播放频率的,'LIST'是放图片帧数据的
'seq '区块和'rate'区块可能会不存在,这时'LIST'里的图片帧顺序就是播放顺序
先用fetch读取.ani文件,并用res.arrayBuffer()转化成arrayBuffer,再用new Uint8Array(arrayBuffer)转化成uint8Array数组。
这就是文件数据,文件数据是按4字节为一行来区分,对uint8Array数组进行String.fromCharCode(uint8Array[i])转换成ASCII,就可以看见开头标识符确实是RIFF
对转化后的数据进行采集,就能获取标识符区块的基本位置
我这个.ani文件没有'seq '区块和'rate'区块,所以直接读取'LIST'里面的icon图片帧就好了。
如何知道图片帧的大小是个问题,根据解析里面讲的,标识符之后的4字节表示的是数据长度,但是我通过视图new DataView(arrayBuffer).getUint32(iconStartIndex+1*4,true);来读取这个数据长度的时候发现,这个数据长度无效且只有真实长度的一半左右大小,也不知道是不是读取方式错误的原因。
经测试,相同icon的位置相减才是图片帧的大小,如第二帧减去第一帧61872-68,每帧的大小是61804,前面的8字节是放icon标识符和数据长度的,通过截取这一段数据转换成Blob,再转成url,就能使用了
不过浏览器的光标最好还是限制大小到32*32,所以还需要通过img元素加载已经得到的url,导入canvas画布裁剪成32*32,再导出图片Blob,再转成url供cursor的url()使用
//解析ani数据,从中提取ani结构数据 analysisAni = (aniUrl: string) => { return fetch(aniUrl) .then((res) => res.arrayBuffer()) .then((arrayBuffer) => { let uint8Array = new Uint8Array(arrayBuffer); let arr = []; for (let i = 0; i < uint8Array.length; i++) { let uint8 = uint8Array[i]; uint8 && arr.push(String.fromCharCode(uint8)); } console.log(uint8Array, arr); let instruction = [ 'RIFF', // 'ACON', 'anih', 'rate', 'seq ', 'LIST', // 'fram', 'IART', 'INAM', // 'INFO', 'icon', ]; let aniData: any = {}; for (let i = 0; i < uint8Array.length; i += 4) { let signBuffer = [ uint8Array[i], uint8Array[i + 1], uint8Array[i + 2], uint8Array[i + 3], ]; let dataSize = [ uint8Array[i + 4], uint8Array[i + 5], uint8Array[i + 6], uint8Array[i + 7], ]; let sign = signBuffer .map((uint8) => { if (typeof uint8 == 'number') { return String.fromCharCode(uint8); } }) .join(''); if (instruction.includes(sign)) { let obj = { sign, index: i, signBuffer, dataSize }; aniData[sign] ? aniData[sign].push(obj) : (aniData[sign] = [obj]); } } console.log(aniData); return { aniData, arrayBuffer, uint8Array, }; }); }; //从ani结构数据提取图片url数组 extractImages = ( arrayBuffer: any, uint8Array: any, aniData: Common.obj, width: number, height: number, ) => { let that = this; //加载图片帧的数组 let promiseList: any[] = []; if (aniData['seq ']) { //创建视图 const view = new DataView(arrayBuffer); //anih开始位置 const anihStartIndex = aniData['anih'][0]?.index; //seq区块开始位置 const seqStartIndex = aniData['seq '][0]?.index; //seq区块数据长度 const seqDataLength = view.getUint32(seqStartIndex + 1 * 4, true); new DataView(arrayBuffer).getUint32(aniData['icon'][0]?.index+1*4,true); let sortArr = []; if (aniData['rate']) { //思路:seq区块和rate区块都存在的情况下,从seq区块获取帧顺序,从rate区块获取帧播放频率 //rate区块开始位置 const rateStartIndex = aniData['rate'][0]?.index; //根据播放顺序,排好帧顺序 for (let i = 0; i < seqDataLength / 4; i++) { let index = uint8Array[seqStartIndex + (2 + i) * 4]; let playbackFrequency = (view.getUint32(rateStartIndex + (2 + i) * 4, true) / 60) * 1000; aniData['icon'][index].playbackFrequency = playbackFrequency; sortArr.push(aniData['icon'][index]); } //获取图片数据 sortArr.forEach((e: any) => { //单帧图片数据长度 let frameDataLength = view.getUint32(e.index + 1 * 4, true); const aniUrl = URL.createObjectURL( //截取图片数据需要减去标识符和数据长度共2个4字节 new Blob( [ new Uint8Array( arrayBuffer, e.index + 2 * 4, frameDataLength - 2 * 4, ), ], { type: 'image/x-icon', }, ), ); promiseList.push( that.resizeIco(aniUrl, e.playbackFrequency, width, height), ); }); //返回Promise数组 return Promise.all(promiseList); } else { //思路:只有seq区块存在,rate区块不存在的情况下,从seq区块获取帧顺序,用anih区块的显示频率做播放频率 // 播放频率,单位毫秒 const playbackFrequency = (view.getUint32(anihStartIndex + 9 * 4, true) / 60) * 1000; //根据播放顺序,排好帧顺序 for (let i = 0; i < seqDataLength / 4; i++) { let index = uint8Array[seqStartIndex + (2 + i) * 4]; sortArr.push(aniData['icon'][index]); } //获取图片数据 sortArr.forEach((e: any) => { //单帧图片数据长度 let frameDataLength = view.getUint32(e.index + 1 * 4, true); const aniUrl = URL.createObjectURL( //截取图片数据需要减去标识符和数据长度共2个4字节 new Blob( [ new Uint8Array( arrayBuffer, e.index + 2 * 4, frameDataLength - 2 * 4, ), ], { type: 'image/x-icon', }, ), ); promiseList.push( that.resizeIco(aniUrl, playbackFrequency, width, height), ); }); return Promise.all(promiseList); } } else { //思路:seq区块和rate区块都不存在的情况下,LIST区块中图像帧的顺序即为播放顺序,用anih区块的显示频率做播放频率 //anih区块起始位置 const anihStartIndex = aniData['anih'][0]?.index; //创建视图 const view = new DataView(arrayBuffer); // 单帧的数据长度 const frameDataLength = aniData['icon'][1]?.index - aniData['icon'][0]?.index; // 播放频率,单位毫秒 const playbackFrequency = (view.getUint32(anihStartIndex + 9 * 4, true) / 60) * 1000; //获取图片数据 aniData['icon']?.forEach((e: any) => { const aniUrl = URL.createObjectURL( //截取图片数据需要减去标识符和数据长度共2个4字节 new Blob( [ new Uint8Array( arrayBuffer, e.index + 2 * 4, frameDataLength - 2 * 4, ), ], { type: 'image/x-icon', }, ), ); promiseList.push( that.resizeIco(aniUrl, playbackFrequency, width, height), ); }); return Promise.all(promiseList); } }; //通过canvas和img重新编辑图片数据为32高宽,并导出url resizeIco = ( blobUrl: string, //图片url路径 frequency: number, //播放频率 width: number, //图片宽 height: number, //图片高 ) => { return new Promise((resolve) => { //初始化canvas画布 const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); //初始化图片,并用canvas导出url const img = new Image(); img.onload = () => { ctx && ctx.drawImage(img, 0, 0, width, height); canvas.toBlob((blob) => { if (blob) { const url = URL.createObjectURL(blob); resolve({ url, frequency }); } }, 'image/x-icon'); }; img.src = blobUrl; // 触发加载 }); };
4-3、处理图片帧生成动态光标动画
根据上面的图片帧数组,共9帧,就能生成动画style了
生成的style是这样的
代码
//生成css动画style generateAnimation = ( aniUrlList: any, animationName: string, defaultCursor: string, ) => { let key = `${animationName}_${new Date().getTime()}`; let keyframesName = key; let aniClassName = `${key}_Class`; let keyframesBody = ''; let pos = 0; //计算调节动画的频率和总时长 let frequencyTotal = aniUrlList.reduce((total: any, e: any) => { if (typeof total == 'number') { return total + e.frequency; } else { return total.frequency + e.frequency; } }); //生成动画样式 let initFrequency: number; aniUrlList.forEach((e: any, i: number) => { keyframesBody += `${pos}% { cursor: url(${e.url}),${defaultCursor};}\n`; //第一个默认为零,所以把它的频率均分给其他帧 if (i == 0) { initFrequency = e.frequency / (aniUrlList.length - 1); } //其他帧的频率会加上第一帧的平均频率 pos += ((e.frequency + initFrequency) / frequencyTotal) * 100; }); let keyFrameContent = `@keyframes ${keyframesName}{ ${keyframesBody} }`; //生成Style const styleContent = `${keyFrameContent} .${aniClassName} { animation: ${keyframesName} ${frequencyTotal}ms ease infinite; }`; return { styleContent, aniClassName }; };
4-4、加载到dom节点上
有了style,把style插入到head中,并设置好类名即可
//插入页面 insertPage = ( dom: string | HTMLElement, styleContent: string, aniClassName: string, ) => { //插入Style const style = document.createElement('style'); style.innerHTML = styleContent; document.head.appendChild(style); //设置Dom的类名 let appElement; if (typeof dom == 'string') { appElement = document.getElementById(dom); } else { appElement = dom; } if (appElement) { this.aniClassName = aniClassName; appElement.classList.add(aniClassName); } //保存dom和style,方便之后的清除 this.aniStyle = style; this.aniDom = appElement; };
4-5、完整代码
ani.ts文件,380行代码就能实现加载.ani文件实现动态光标的功能
class AniModule { constructor() {} aniStyle: any; //Style变量 aniDom: any; //Dom变量 aniClassName: any; //ani动画类名 timer: number = 0; //计时器,防止清理陷入死循环 //导入ani路径 putInAni = async ( aniUrl: string, dom: string | HTMLElement = 'body', animationName: string = 'ani', defaultCursor: string = 'auto', width: number = 32, height: number = 32, ) => { // 解析ani数据,从中提取ani结构数据 let { aniData, arrayBuffer, uint8Array } = await this.analysisAni(aniUrl); //从ani结构数据提取图片url数组 let aniUrlList = await this.extractImages( arrayBuffer, uint8Array, aniData, width, height, ); //通过图片url数组生成对应的动画Style let { styleContent, aniClassName } = this.generateAnimation( aniUrlList, animationName, defaultCursor, ); //插入到页面中去 this.insertPage(dom, styleContent, aniClassName); }; //解析ani数据,从中提取ani结构数据 analysisAni = (aniUrl: string) => { return fetch(aniUrl) .then((res) => res.arrayBuffer()) .then((arrayBuffer) => { let uint8Array = new Uint8Array(arrayBuffer); let arr = []; for (let i = 0; i < uint8Array.length; i++) { let uint8 = uint8Array[i]; uint8 && arr.push(String.fromCharCode(uint8)); } console.log(uint8Array, arr); let instruction = [ 'RIFF', // 'ACON', 'anih', 'rate', 'seq ', 'LIST', // 'fram', 'IART', 'INAM', // 'INFO', 'icon', ]; let aniData: any = {}; for (let i = 0; i < uint8Array.length; i += 4) { let signBuffer = [ uint8Array[i], uint8Array[i + 1], uint8Array[i + 2], uint8Array[i + 3], ]; let dataSize = [ uint8Array[i + 4], uint8Array[i + 5], uint8Array[i + 6], uint8Array[i + 7], ]; let sign = signBuffer .map((uint8) => { if (typeof uint8 == 'number') { return String.fromCharCode(uint8); } }) .join(''); if (instruction.includes(sign)) { let obj = { sign, index: i, signBuffer, dataSize }; aniData[sign] ? aniData[sign].push(obj) : (aniData[sign] = [obj]); } } console.log(aniData); return { aniData, arrayBuffer, uint8Array, }; }); }; //从ani结构数据提取图片url数组 extractImages = ( arrayBuffer: any, uint8Array: any, aniData: Common.obj, width: number, height: number, ) => { let that = this; //加载图片帧的数组 let promiseList: any[] = []; if (aniData['seq ']) { //创建视图 const view = new DataView(arrayBuffer); //anih开始位置 const anihStartIndex = aniData['anih'][0]?.index; //seq区块开始位置 const seqStartIndex = aniData['seq '][0]?.index; //seq区块数据长度 const seqDataLength = view.getUint32(seqStartIndex + 1 * 4, true); new DataView(arrayBuffer).getUint32(aniData['icon'][0]?.index+1*4,true); let sortArr = []; if (aniData['rate']) { //思路:seq区块和rate区块都存在的情况下,从seq区块获取帧顺序,从rate区块获取帧播放频率 //rate区块开始位置 const rateStartIndex = aniData['rate'][0]?.index; //根据播放顺序,排好帧顺序 for (let i = 0; i < seqDataLength / 4; i++) { let index = uint8Array[seqStartIndex + (2 + i) * 4]; let playbackFrequency = (view.getUint32(rateStartIndex + (2 + i) * 4, true) / 60) * 1000; aniData['icon'][index].playbackFrequency = playbackFrequency; sortArr.push(aniData['icon'][index]); } //获取图片数据 sortArr.forEach((e: any) => { //单帧图片数据长度 let frameDataLength = view.getUint32(e.index + 1 * 4, true); const aniUrl = URL.createObjectURL( //截取图片数据需要减去标识符和数据长度共2个4字节 new Blob( [ new Uint8Array( arrayBuffer, e.index + 2 * 4, frameDataLength - 2 * 4, ), ], { type: 'image/x-icon', }, ), ); promiseList.push( that.resizeIco(aniUrl, e.playbackFrequency, width, height), ); }); //返回Promise数组 return Promise.all(promiseList); } else { //思路:只有seq区块存在,rate区块不存在的情况下,从seq区块获取帧顺序,用anih区块的显示频率做播放频率 // 播放频率,单位毫秒 const playbackFrequency = (view.getUint32(anihStartIndex + 9 * 4, true) / 60) * 1000; //根据播放顺序,排好帧顺序 for (let i = 0; i < seqDataLength / 4; i++) { let index = uint8Array[seqStartIndex + (2 + i) * 4]; sortArr.push(aniData['icon'][index]); } //获取图片数据 sortArr.forEach((e: any) => { //单帧图片数据长度 let frameDataLength = view.getUint32(e.index + 1 * 4, true); const aniUrl = URL.createObjectURL( //截取图片数据需要减去标识符和数据长度共2个4字节 new Blob( [ new Uint8Array( arrayBuffer, e.index + 2 * 4, frameDataLength - 2 * 4, ), ], { type: 'image/x-icon', }, ), ); promiseList.push( that.resizeIco(aniUrl, playbackFrequency, width, height), ); }); return Promise.all(promiseList); } } else { //思路:seq区块和rate区块都不存在的情况下,LIST区块中图像帧的顺序即为播放顺序,用anih区块的显示频率做播放频率 //anih区块起始位置 const anihStartIndex = aniData['anih'][0]?.index; //创建视图 const view = new DataView(arrayBuffer); // 单帧的数据长度 const frameDataLength = aniData['icon'][1]?.index - aniData['icon'][0]?.index; // 播放频率,单位毫秒 const playbackFrequency = (view.getUint32(anihStartIndex + 9 * 4, true) / 60) * 1000; //获取图片数据 aniData['icon']?.forEach((e: any) => { const aniUrl = URL.createObjectURL( //截取图片数据需要减去标识符和数据长度共2个4字节 new Blob( [ new Uint8Array( arrayBuffer, e.index + 2 * 4, frameDataLength - 2 * 4, ), ], { type: 'image/x-icon', }, ), ); promiseList.push( that.resizeIco(aniUrl, playbackFrequency, width, height), ); }); return Promise.all(promiseList); } }; //通过canvas和img重新编辑图片数据为32高宽,并导出url resizeIco = ( blobUrl: string, //图片url路径 frequency: number, //播放频率 width: number, //图片宽 height: number, //图片高 ) => { return new Promise((resolve) => { //初始化canvas画布 const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); //初始化图片,并用canvas导出url const img = new Image(); img.onload = () => { ctx && ctx.drawImage(img, 0, 0, width, height); canvas.toBlob((blob) => { if (blob) { const url = URL.createObjectURL(blob); resolve({ url, frequency }); } }, 'image/x-icon'); }; img.src = blobUrl; // 触发加载 }); }; //生成css动画style generateAnimation = ( aniUrlList: any, animationName: string, defaultCursor: string, ) => { let key = `${animationName}_${new Date().getTime()}`; let keyframesName = key; let aniClassName = `${key}_Class`; let keyframesBody = ''; let pos = 0; //计算调节动画的频率和总时长 let frequencyTotal = aniUrlList.reduce((total: any, e: any) => { if (typeof total == 'number') { return total + e.frequency; } else { return total.frequency + e.frequency; } }); //生成动画样式 let initFrequency: number; aniUrlList.forEach((e: any, i: number) => { keyframesBody += `${pos}% { cursor: url(${e.url}),${defaultCursor};}\n`; //第一个默认为零,所以把它的频率均分给其他帧 if (i == 0) { initFrequency = e.frequency / (aniUrlList.length - 1); } //其他帧的频率会加上第一帧的平均频率 pos += ((e.frequency + initFrequency) / frequencyTotal) * 100; }); let keyFrameContent = `@keyframes ${keyframesName}{ ${keyframesBody} }`; //生成Style const styleContent = `${keyFrameContent} .${aniClassName} { animation: ${keyframesName} ${frequencyTotal}ms ease infinite; }`; return { styleContent, aniClassName }; }; //插入页面 insertPage = ( dom: string | HTMLElement, styleContent: string, aniClassName: string, ) => { //插入Style const style = document.createElement('style'); style.innerHTML = styleContent; document.head.appendChild(style); //设置Dom的类名 let appElement; if (typeof dom == 'string') { appElement = document.getElementById(dom); } else { appElement = dom; } if (appElement) { this.aniClassName = aniClassName; appElement.classList.add(aniClassName); } //保存dom和style,方便之后的清除 this.aniStyle = style; this.aniDom = appElement; }; //清除style和取消动画 cancelCursor = () => { //清除dom,移除dom节点和类名 const appElement = this.aniDom; if (appElement) { appElement.classList.remove(this.aniClassName); this.aniDom = null; this.aniClassName = null; this.timer = 0; } //清除style后,移除style节点 const style = this.aniStyle; if (style) { document.head.removeChild(style); this.aniStyle = null; this.timer = 0; } //如果未加载好ani动画的时候,就已经清理了,则用计时器延后清理 if (!appElement && !style) { //防止循环调用 this.timer += 1; if (this.timer > 30) { return; } //定时器延后清理 setTimeout(() => { this.cancelCursor(); }, 500); } }; } const ani = new AniModule(); export const { putInAni, cancelCursor } = ani;
在其他文件中使用
import { putInAni, cancelCursor } from '#/utils/ani'; //下载url路径文件 export function downloadUrlFile(url: string, download: string = '') { putInAni( 'https://edu.runhebao.cn/upload/whai/f8b6c2a7e5913f6f39426dcce80404922c91cac458b5c8ec5f0d8c341cec7715.ani', 'app', ); // putInAni('/src/assets/other/aero_working.ani', 'body'); // putInAni('/public/aero_working.ani', 'body'); return fetch(url) .then((res) => res.blob()) .then((blob) => { let url = window.URL.createObjectURL(blob); let a = document.createElement('a'); a.href = url; if (download == '') { a.download = url.split('/').pop() ?? ''; } else { a.download = download; } a.click(); a.remove(); window.URL.revokeObjectURL(url); cancelCursor(); }); }