如何在浏览器中使用动态光标

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();
    });
}

 

posted @ 2025-04-27 20:00  Pavetr  阅读(89)  评论(0)    收藏  举报