02月06, 2021

Web前端剪切板文本分享到文件发送

前言  

现在前端富交互能力越来越强,也有很多产品基于前端技术进行离线应用开发或在线应用体验增强;这其中剪切板操作也是一个经常会亮相客串的一个基础能力。

今天这篇文章我们就一起整理一下关于剪切板读写操作的一些实践。会包含浏览器端、Electron客户端两种不同的业务场景下,一些具体的需求实现示例。

实现一个“复制”按钮,方便内容分享传播

在网页上一段文本后添加一个“复制”按钮并不是啥新鲜的事情,比如“复制分享网址”、“帮我砍一刀”...,早先FLASH还红火的时候,也有很多是基于FLASH实现的。

alt

实际基于浏览器自身JS能力实现也非常方便:

<textarea id="txt">这里是将要被复制的文案</textarea>
<button id="btn" type="button">点我复制</button>

<script>
  const iptEl = document.getElementById('txt');
  const btnEl = document.getElementById('btn');
  btn.onclick = function() {
    iptEl.select();
    document.execCommand('copy');
    alert('复制完毕。');
  };
</script>

点这里在线试验一下效果

上面代码中复制文本框中的内容到剪切板,主要使用了 document.execCommand('copy'),我们 通过 Can I use 查看 目前各浏览器兼容都挺好,而且也非常方便,很多富文本编辑器厂商也还是在依赖这个API。

alt

不过需要注意,在相关标准及浏览器厂商的WEB API文档中已经警告开发者这是个“已废弃”的API了。

在新的标准和JS API实现中,已经为我们提供了专门用于剪切板操作的Clipboard API

当然我们也就可以基于这个新的API,来实现上面相同的功能。只需要将onclick响应中的代码改成下面这样:

btnEl.onclick = () => {
  navigator.clipboard.writeText(iptEl.value).then(() => {
    alert('复制完毕。');
  });
};

点这里在线试验一下效果

修改后,主要是使用了 clipboard.writeText将文本写入剪切板。同样我们也可以去 Can I use 查看一下在各浏览器的兼容性

alt

和前一个方案比较,兼容性及浏览器覆盖率目前还是差一些;不过就API能力来说,clipboard可要比之前的 execCommand 更明确、更强大的多了。

修改将要写入剪切板的文本内容

前面介绍了怎么在网页里添加一个“复制”按钮,来让用户触发复制一段文案的操作。

不妨就这个功能再稍微发散考虑一下: 我们知道触发复制的行为当然不会只有这一种方式(比如也可以“Command+C || Ctrl + C”这种快捷键的形式),那么当用户复制一段文本内容的时候,我们能不能进行二次加工呢?

比如,你可能早就留意到,在某一些网页中复制一段选中文本,粘贴的时发现除了选择内容,后面还会有"原文出处、版权信息"等,是怎么做到的呢?

这里要修改将要写入剪切板的内容,可以将功能逻辑分成3部分:

  1. 监听文本复制的操作行为,阻止浏览器默认行为。
  2. 获取用户选择的目标文本内容,并追加内容。
  3. 将文本内容写入剪切板。

第1步,监听document或目标元素的copy事件就可以。

第2步,要获取用户选中的内容,需要用到另外一个能力 Selection API ;下面示例代码中考虑兼容性实现了一个工具函数 getSelectionTxt

第3步,我们在前面的示例中,已经实践过2种方案,这里我们再换一个方案,使用事件对象暴露的 clipboardData.setData

function getSelectionTxt() {
  if (document.selection) {
    return document.selection.createRange().text;
  } else {
    return String(window.getSelection());
  }
}

// 监听 copy 事件
document.addEventListener('copy', (evt) => {
  const { clipboardData } = evt;
  if (!clipboardData) return;

  // 获取选中文本
  let txt = getSelectionTxt();
  if (!txt) return;

  // 有要操作的目标内容,阻止浏览器默认行为
  evt.preventDefault();

  // 追加内容
  txt += '\n\n 【原文地址:https://blog.pyzy.net/post/clipboard.html 】';

  // 写入剪切板
  clipboardData.setData('text/plain', txt);
});

在线试验一下

认识剪切板中的内容

上面我们都是基于纯文本的实践示例,实际剪切板里不止是可以用于文本内容的复制粘贴。

如果选中网页中一段带格式的富文本内容,在一个富文本编辑器粘贴,会连带格式粘贴过去。

如果我们使用微信等IM的截图功能、或者使用MacOS系统中的 Command+Shift+4+Control截图到剪切板,那么就可以在任意支持图片粘贴的地方粘贴一个图片过去,也可以在网页中粘贴实现上传。

另外在使用一些IM工具时候,通常也允许我们复制磁盘中的任意文件或文件夹,在会话交流界面粘贴发送出去。

更有代表意义的是:在Excel等Office编辑器中复制一段内容,下面也通过具体代码示例来逐步了解一下。

跟前面的示例监听 copy 类似地,可以通过监听 paste 粘贴事件,来读取剪切板中数据内容:

document.addEventListener('paste', (evt) => {
  const { clipboardData } = evt;
  console.log('clipboardData:', clipboardData);
});

可以将以上代码在网页中执行。

之后打开一个 Excel 表格,任意选中几个单元格,并“复制”内容。

alt

再回到刚才执行paste事件监听的页面,Command+V(如果你是Windows系统需要Ctrl+V)。

可以看到控制台打印出的clipboardData中,types字段值数组长度为3,另外还有itemsfiles等字段。

alt

我们接下来对代码稍加改造,打印看一下typesitems字段值中具体是什么内容:

document.addEventListener('paste', (evt) => {
  const { types = [], items=[] } = evt.clipboardData || {};
  console.log('clipboardData.types:', [...types]);
  console.log(
    'clipboardData.items:',
    [...items].map(
       ({ kind, type }) => ({ kind, type })
    )
  );
});

神奇的事情发生了,一次复制行为,原来是可能会产生多种不同类型可用于粘贴的数据的:

alt

其中types字段的内容分别为 ["text/plain", "text/html", "Files"],对应到 items字段中的数据,我们发现这里的Files类型是一个typeimage/png的文件对象。

我们再改造一下示例代码,实现一个可以将"text/plain", "text/html", "image/png"这3种类型内容都分别打印到页面上的功能,看看剪切板里具体内容到底是什么东东:

<!DOCTYPE html>
<html>
  <head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>剪切板内容读取试验</title>
  <style>
    h2 {
      border-bottom: solid 1px #eee;
      font-size: 16px;
      padding: 10px;
    }

    #container {
      border: solid 1px #eee;
      padding: 10px;
      background: #f9fdff;
    }

    #container:empty:after {
      content: '请执行复制操作后,在当前页面尝试Command+V或Ctrl+V。';
      color: #aaa;
      font-size: 14px;
    }

    h3 {
      border-bottom: solid 1px #eee;
      font-size: 16px;
      padding: 0 0 10px;
      margin: 10px 0;
    }

    textarea {
      display: block;
      width: 95%;
      height: 200px;
      padding: 8px 10px;
      box-shadow: 1px 1px 3px rgb(0 0 0 / 20%) inset;
      border-radius: 4px;
    }

    img {
      display: block;
      width: 100%;
    }
  </style>
</head>
<body>
  <h2>剪切板内容:</h2>
  <div id="container"></div>
  <script>
    document.addEventListener('paste', (evt) => {
      container.innerHTML = '';
      const items = Array.from((evt.clipboardData || {}).items || []);
      items.forEach((item, i) => {
        const { kind, type } = item;
        const entry = item.webkitGetAsEntry && item.webkitGetAsEntry();
        const isDirectory = entry && entry.isDirectory;
        const h3 = document.createElement('h3');
        h3.innerText = `序号:${i};kind:${kind}; type:${type};`;
        container.appendChild(h3);
        let previewEl = null;
        if (isDirectory) {
          previewEl = document.createElement('a');
          previewEl.innerText = '文件夹:' + entry.fullPath;
        } else if (kind === 'file') {
          const file = item.getAsFile();
          const url = URL.createObjectURL(file);
          if (/image/i.test(type)) {
            previewEl = document.createElement('img');
            previewEl.src = url;
          } else {
            previewEl = document.createElement('a');
            previewEl.href = url;
            previewEl.download = file.name;
            previewEl.innerText = file.name;
          }
        } else {
          previewEl = document.createElement('textarea');
          item.getAsString((str, ...args) => {
            console.log('str', str, ...args)
            previewEl.value = str;
          });
        }
        container.appendChild(previewEl);
      });
    });
  </script>
</body>
</html>

以上代码和之前一样,也可以“在线试验一下”

将刚刚复制到剪切板的Execl单元格内容在这个页面中粘贴,可以看到如下效果:

alt

右边内容展示区中,从上往下依次是:带有定界符的纯文本内容、带有CSS样式及table代码的HTML片段、一张对选择区域截图产生的图片文件。

我们再试验一下前面说到的使用微信截个图:

alt

也还是打开上面的剪切板粘贴试验页,进行粘贴试验:

alt

哇欧,我们在网页中通过剪切板API读取到了截取的图片 ---- 并且跟前面Excel复制行为比较仅有一个截图数据。

试试在文章左边Blog作者头像上右键复制图片:

alt

也还是回到试验页,Command+V 粘贴试验:

alt

哦,原来在网页里复制的图片,可以通过剪切板拿到一端富文本HTML代码和一个图片文件对象。

那么我们从任意磁盘目录中选择并Command+C复制一个图片呢?

alt

剪切板粘贴试验页进行粘贴读取到的内容:

alt

可以看到,我们在网页中拿到了被复制图片的一个text/plain类型的string文件名和一个image/png类型的File对象。

厉害了,通过上面的示例实现的代码能力,已经实现了剪切板资源读取、文件的File对象获取、图片预览。

如果能将File直接发送给服务端,那么一个通过剪切板的复制粘贴能力来上传分享图片、发送截图的能力就可以实现了。

写一张图片到剪切板中

上面是从页面或磁盘里复制图片资源,我们也可以让用户点一个按钮时,主动写一个图片到剪切板中

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="robots" content="noindex">
  <meta name="viewport" content="width=device-width">
  <title>图片复制</title>
</head>
<body>
  <!-- https://p3.ssl.qhimg.com/t01f8b7c8780afb7342.jpg -->
  <img id="img" src='https://p3.ssl.qhimg.com/t011e94f0b9ed8e66b0.png' /><br>
  <button id="btn" type="button">复制</button>
  <p id="ret">点击“复制”,再去粘贴试试</p>
  <script>
    btn.onclick = async () => {
      const res = await fetch(img.src);
      const blob = await res.blob();
      const item = new ClipboardItem({'image/png': blob});
      navigator.clipboard.write([item]).then(() => {
        ret.innerText = '复制完毕。';
      }, (err) => {
        console.error(err);
        ret.innerText = '复制失败!' + err;
      });
    };

    /*
    在被 iframe 的场景,记得增加 allow="clipboard-write" 属性,来设置好权限:
    ifrEl = document.querySelector('[src="https://code.h5jun.com/romi/edit?js,output"]');
    ifrEl.setAttribute('allow', 'clipboard-write')
    ifrEl.src = ifrEl.src;
    */
  </script>
</body>
</html>

剪切板操作权限判断

剪切板读写会涉及用户隐私问题,有可能默认是禁止站点进行该操作的,正式业务应用中,最好也做一下是否具备剪切板读写权限的判断

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="robots" content="noindex">
  <meta name="viewport" content="width=device-width">
  <title>检查剪切板操作权限</title>
  <style>
    #box {
      padding: 10px 20px;
    }
    #box:empty:after {
      color: #999;
      content: '请稍候...'
    }
    #box h3 {
      font-weight: 400;
      margin: 0 0 4px;
      background: #eee;
      padding: 5px 10px;
    }
    p {
      margin: 10px 20px;
      color: #999;
    }
    #p:empty:after {
      content: '...操作结果将显示在这里...'
    }
  </style>
</head>
<body>
  <p>权限状态可能会是:prompt、granted、denied</p>
  <div id="box"></div>
  <p id="p"></p>
  <script>
    const confs = [
      { name: 'clipboard-read' },
      { name: 'clipboard-write' }
    ];

    Promise.all(
      confs.map(
        (conf) => navigator.permissions.query(conf)
      )
    ).then((pers) => {
      box.innerHTML = pers.map((status, i) => {
        const { name } = confs[i];
        const stateId = `state_${i}`;
        status.onchange = () => { 
          document.getElementById(stateId).innerText = status.state;
        };
        return `
          <h3>
            权限项:${name},<br />
            状态值:<span id="${stateId}">${status.state}</span>,<br />
            试一下:<button onclick="runTest('${name}')">点这里</button>
          </h3>
        `;
      }).join('');
    });

    const tests = {
      'clipboard-read': async () => {
        const txt = await navigator.clipboard.readText();
        p.innerText = '读取结果:' + txt;
      },
      'clipboard-write': async () => {
        await navigator.clipboard.writeText('test text');
        p.innerText = '写入完毕';
      },
    };
    window.runTest = (name) => tests[name]();
  </script>
</body>
</html>

将File上传到服务端

完整的文件上传示例,必须依赖服务端,这里只提供纯前端伪代码的几种方案:

首先,如果你的服务端有一个独立的用于文件上传的接口,基于XMLHttpRequest(也就是熟知的AJAX)方式发送File对象给服务端即可:

const xhr = new XMLHttpRequest();
xhr.onload = () => {
    console.log('上传结果:', xhr.responseText);
};
xhr.open('POST', './uploadFile', true);
xhr.send(file);

如果服务端接口在接受文件内容时,还要求有别的必填字段信息,往服务端send一个FormData对象即可:

const formData = new FormData();
formData.append('type', 'image');
formData.append('file', file);

const xhr = new XMLHttpRequest();
xhr.onload = () => {
    console.log('上传结果:', xhr.responseText);
};
xhr.open('POST', './uploadFile', true);
xhr.send(formData);

如果服务端接口还额外要求必须携带一些自定义请求头字段信息,也可以改成使用 Fetch API 来发送 formData 给服务端。

fetch('./uploadFile', {
   method: 'POST',
   body: formData,
   headers: new Headers({
     'Content-Type': 'application/json'
   })
}) .then((res) => { ... });

当然,文件上传并不会这么简单,比如还可能会涉及到传输进度同步、用户主动取消传输、大文件分片、文件秒传等等,不符合这里的主题,就不展开讨论了。

浏览器端能否通过复制发送文件或文件夹?

当剧情发展到这里,聪明的你也许已经意识到:好像哪里不太对劲,是不是刻意隐瞒了一些问题?

又或者,聪明的你早就发现了,前面基于剪切板传递的都是纯文本字串、富文本HTML、或者静态图片啊!

我们先来复制一个GIF动图,比如下面这个:

alt

到前面的剪切板粘贴试验页粘贴一下,看看效果:

alt

好的,类别kind: string 对应内容和前面示例中发现的规律表现一致,我们继续看。

诶呦,喂!不对啊,明明复制的是image/gif,图片内容咋也变成了和前面复制个png一样,你看它,不会动了嘿!

先暂且按下不管,我们干脆再多一些尝试、复制点别的文件试试,比如分别尝试粘贴一个CSS文件、粘贴一个JS文件、粘贴一个HTML文件、粘贴一组图片、粘贴一个文件夹、粘贴一组任意文件:

alt

WTF!! 什么烂七八糟的?拿不到文件信息?

对了(突然灵鸡一动),记得么?前面我们尝试打印clipboardData对象到控制台的时候,和items平级的有个files字段诶。

我们再在粘贴时多打印一下这个files字段,看看这里面会不会就是被复制的文件集合。

先来一组任意文件:

alt

啥也没读出来啊。再来一组图片试试:

alt

复制了3张图片,但 Files 里只读出了1张图片。

看来是根本无法像拖拽上传那样获得要使用的文件列表啊?!

【2021-11-24 作者注】

旧有版本比如 Chrome 80 是无法按预期读取到FileList数据的;

但此刻(2021-11-24)类似 Chrome 95.0.4638.69 已经能按预期读取到文件列表。

所以如果你是 Electron 场景、又能切换到比较新的Chrome核,那么可以无视下一个章节内容、而直接使用WEB前端JS API的方案了。

Electron 场景能否通过复制发送文件或文件夹?

浏览器中看来无解了,但是WEB前端的魔爪可是早就已经不止于浏览器中了,比如我们是可以借助 Electron 开发离线应用,并拓展使用一些浏览器中不具备的 Native 能力。

而且,刚好笔者涉及当前需求的产品场景就是同时支持浏览器端和Electron包壳两种场景的,那么我们不妨也调研一下。

通过 Electron 剪贴板 API可以看到,当下开放的方法能力实际和浏览器端没有太大区别,文档告诉我们可以通过剪切板的读写的数据或文件资源也是比较基础的文本、富文本、特殊标记语言文本、静态Image资源数据。

Electron社区有人说:通过Mac剪切板查看器示例程序发现应该能通过clipboard.read('NSFilenamesPboardType')读取到一个被复制的文件或文件夹列表的XML格式描述文本:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <array>
    <string>/path/to/file1.ext</string>
    <string>/path/to/file2.ext</string>
  </array>
</plist>

该XML内容的plist节点下的信息就是描述了被复制文件或文件夹的路径。

虽然不能拿到文件对象,但这可是Electron端哦。既然能拿到文件路径,再在页面里通过路径读取到文件的buffer不也就能做很多后续文件操作能力了?

太棒了,不妨按照程序逻辑流程逐步试验一下!

首先,需要从剪切板中拿到的这个XML里提取出被复制的文件或文件夹的路径:

const { clipboard } = require('electron');

function getClipboardPaths() {
  const filePathsXML = clipboard.read('NSFilenamesPboardType') || '';
  return (filePathsXML.match(/<string>([^<]*)<\/string>/gim) || []).map(
    (filePath) => {
      return filePath
        .replace(/(^<string>|<\/string>$)/g, '')
        .replace(/&amp;/g, '&')
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>');
    }
  );
}

如果我们复制了2个文件和一个文件夹,上面的代码可以帮我们读取到如下数组:

[
  '/path/to/folder1',
  '/path/to/file1.ext',
  '/path/to/file2.ext',
]

上面的 /path/to/folder1 是一个文件夹,如果我们的目的是要复制粘贴文件,那么递归遍历展开文件夹也是必须的:

const fs = require('fs');
const path = require('path');

async function flatPaths(filePathsArr) {
  const filePaths = [];
  await Promise.all(
    filePathsArr.map((filePath) => {
      return new Promise((resolve) => {
        fs.stat(filePath, (err, stats) => {
          if (!err) {
            if (stats.isDirectory()) {
              fs.readdir(filePath, async (err, files) => {
                if (!err && files) {
                  const filesPaths = files.map((fileName) => {
                    return path.join(filePath, fileName);
                  });
                  const flatFilesPaths = await flatPaths(filesPaths);
                  filePaths.push(...flatFilesPaths);
                }
                resolve();
              });
              return;
            } else if (stats.isFile()) {
              filePaths.push(filePath);
            }
          }
          resolve();
        });
      });
    })
  );
  return filePaths;
}

接下来是重点了,我们先简单粗暴一些,直接循环将前面加工拿到的所有文件绝对路径通过fs.readFile读取出文件的buffer数据。

const fs = require('fs');
const path = require('path');

async function getClipboardFiles(fileAbsPathsArr) {
  const files = [];
  await Promise.all(
    fileAbsPathsArr.map((filePath) => {
      return new Promise((resolve) => {
        fs.readFile(filePath, (err, data) => {
          if (!err) {
            files.push({
              filePath,
              fileName: path.basename(filePath),
              data, // 可用于在Web端JS new File([data], fileName)
            });
          }
          resolve();
        });
      });
    })
  );
  return files;
}

这次经过getClipboardFiles之后我们拿到的是像下面这样,带有文件绝对路径、文件名、文件buffer数据的数组:

[
  {
    filePath: '/path/to/folder1/fileN.ext',
    fileName: 'fileN.ext',
    data: [...Uint8Array...],
  },
  {
    filePath: '/path/to/file1.ext',
    fileName: 'file1.ext',
    data: [...Uint8Array...],
  },
  ...,
]

现在可以回到我们熟悉的 Web 页面中JS交互逻辑里了。

document.addEventListener('paste', async (evt) => {
  const clipboardPaths = await flatPaths(getClipboardPaths());
  if (clipboardPaths.length) {
    evt.stopPropagation();
    evt.preventDefault();
  } else {
    return; // any ...
  }
  // 得到剪切板中文件数据们
  const clipboardFiles = await getClipboardFiles(clipboardPaths);
  // 使用 new File 生成可用于Web端的 File 对象
  const fileList = clipboardFiles.map(({ fileName, data }) => {
    return new File([data], fileName);
  });

  /* 文件预览、发送.... sendFiles(fileList); */
});

到这里,我们在Electron端发送文件的效果也就实现了。

拿到文件列表就可以后面的折腾了,比如用户发送前,给一个文件预览,二次确认的列表:

alt

另外,Electron场景的NSFilenamesPboardType还有一个非常值得令人振奋的地方,不但可以读取剪切板文件列表,也可以写任意文件列表到剪切板了(注:笔者该试验仅限 MacOS 场景中)。

const { clipboard } = require('electron');

clipboard.writeBuffer(
  'NSFilenamesPboardType',
  Buffer.from(`
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
      <array>
        <string>/path/to/file1.ext</string>
        <string>/path/to/file2.ext</string>
      </array>
    </plist>
  `)
)

写在最后

首先,请读者注意:以上所有示例代码更侧重在思路示意,甚至可以说是伪代码,如果要在正式业务场景应用,请一定酌情调整、增加严谨性、健壮性、兼容性相关考量。

另外,相关标准委员会、浏览器厂商(比如:Chrome)、Electron官方也都还在不断迭代扩展Clipboard API,本文的方案只是基于时下的形式考虑,如果发现不符欢迎随时指正,以免误导。

最后,郑重感谢您花时间在本文中,如果有哪些点描述不准确或需要讨论的,也欢迎评论留言。

新年快乐~

本文链接:http://blog.pyzy.net/post/clipboard.html

-- EOF --

Comments

可以发邮件 huzunjie@pyzy.net 或移步到 https://github.com/huzunjie/blog.pyzy.net/issues 评论交流。