11月21, 2021

跨网页或APP数据文件共享续篇

前言

我在前面一篇文章《记一个网页端IM文本编辑器的演进过程 https://blog.pyzy.net/post/editor.html )》虽然没有提到 DataTransfer,但实际也涉及到了从剪切板(clipboardData)或拖拽元素(dataTransfer)来读取内容、插入编辑区的功能。

而在更早的另一篇文章《Web前端剪切板文本分享到文件发送https://blog.pyzy.net/post/clipboard.html )》一文里,也有涉及到过剪切板(clipboardData)或拖拽磁盘文件(dataTransfer)读取识别内容达成文件上传发送相关的介绍。

今天,再通过具体示例,接续前面两篇文章内容,来聊一聊“通过DataTransfer 读取使用剪切板HTML富文本、以及通过拖拽分享数据内容”的两个场景化应用。

剪切板富文本的应用

先接续上最近的、也就是上一篇编辑器相关的能力拓展。

剪切板HTML内容的读取

在《Web前端剪切板文本分享到文件发送 》一文的《认识剪切板中的内容》一节中,有介绍通过监听 document 的 paste 粘贴事件来读取剪切版内容,通过当时的 Demo工具 也可以直接在线看到能从剪切板读取的内容。

比如复制前面正在看到的这段文字,到Demo工具页粘贴,可以看到能读取到text/plaintext/html两种类型的内容:

alt

简化后的“通过对粘贴事件的监听读到剪切板中的HTML内容”代码如下:

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

HTML内容特定场景的二次利用

我们在之前《记一个网页端IM文本编辑器的演进过程 https://blog.pyzy.net/post/editor.html )》中有介绍,当时实现的这个“富文本编辑器”,并不是能接受任意HTML的,有按照场景化需求特意定制的能力实现

所以还需要给编辑器添加一个 editor.insertHTML(html)的能力,将HTML内容还原成符合编辑器需要的内容就可以了「这里需要留意是否可能产生XSS风险、而需要过滤防范」。

这其实也简单,因为之前实现的“富文本与正文数据互转 ”中已经实现了对普通HTML节点树进行过滤、转换为编辑器所需JSON描述内容的能力 richTextNodes2MsgData(richTextEl),最后再通过 insertNodes 插入编辑器即可。

所以这里只要从任意三方复制的 html 内容是能符合编辑器合法输入元素规则的,都可以很方便的还原成可二次使用的数据。

同理,如果从Execl复制一个表格,要在自己的WEB网页应用中粘贴还原出表格,自然也可以做到,只要做好特定场景的HTML内容格式化转换能力就够了。

主动写HTML内容到剪切板

有时候我们也会遇到主动写HTML富文本内容到剪切板的情况,按最新API能力可以如下实现:

const type = 'text/html';
navigator.clipboard.write([
  new ClipboardItem({
    [type]: new Blob(
        [`<span>html content...</span>`],
        { type }
    ),
  }),
]).then(
  () => console.log('ok'), 
  (err) => console.log('err', err)
);

但当下(撰写本文时)各浏览器对 Clipboard API 能力的支持程度还不够理想。

比如:支持clipboard.write的浏览器,也未必支持write text/html内容到剪切板;而支持text/html的浏览器可能也未必开启了剪切板操作权限等等。

如果要考虑兼容性降级支持,来实现一个 HTMLElement 的复制,不妨试一下下面这个方案:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="robots" content="noindex">
  <meta name="viewport" content="width=device-width">
  <title>复制HTML元素</title>
</head>
<body>
  <div id="el">
    <button id="btn">复制</button>
    <p id="ret">点击复制试试</p>
  </div>
  <script>
    btn.onclick = async () => {
      const ret = await copyElement(el);
      ret.innerText = (
        ret === true ? '复制成功' : `复制失败: ${ret}`
      );
    };

    async function copyElement(el) {
      try {
        const type = 'text/html';
        const text = 'text/plain';
        const ret = await navigator.clipboard.write([
          new ClipboardItem({
            [type]: new Blob([el.outerHTML], { type }),
            [text]: new Blob([el.innerText], { type: text }),
          }),
        ]).then(() => true, (err) => {
          console.log('[clipboard.write] err:', err);
          return false;
        });
        if (ret) return ret;
      } catch (err) {
        // 忽略异常,走下面的备用方案
        console.log('[Error] Clipboard write:', err);
      }
      try {
        // 【注意】目标元素CSS属性user-select 不能是禁用选择的

        /* 选中目标元素 sta */
        const pEl = el.parentElement;
        const idx = [...pEl.childNodes].indexOf(el);
        const ra = document.createRange();
        ra.setStart(pEl, idx);
        ra.setEnd(pEl, idx + 1);
        const sel = window.getSelection();
        sel.removeAllRanges();
        sel.addRange(ra);
        /* 选中目标元素 end */

        /* 复制选中内容 */
        document.execCommand('Copy');

        /* 移除选择状态 */
        sel.removeAllRanges();

        return true;
      } catch (err) {
        return '[Error] Set Selection And Copy' + err;
      }
    };
  </script>
</body>
</html>

上面的复制HTML元素到剪切板的代码实现,你也可以点这里在线试一下

网页中通过拖拽分享数据

HTML5中DragDrop相关的API能力,早已经不是个新的话题了。

通过拖拽文件放置到网页中来达成上传、网页中通过拖拽商品放置购物车实现购买等等,很多具体应用场景,大家也许早就不知不觉中有过相关的交互体验。

DragDrop又完全可以分开了单独使用。

网页中接收文件拖入

比如可以通过监听 document 的 drop 事件,感知到文件的放入,通过 event.dataTransfer 拿到拖入的数据信息:

document.addEventListener('drop', (e) => {
  e.preventDefault();
  console.log(e.dataTransfer)
});
document.addEventListener('dragover', (e) => {
  e.preventDefault();
});

而浏览器对文件拖入是有默认行为的,比如HTML会直接被打开,所以当你想自己接管数据拖入的后续行为时,必须知道 preventDefault 是必须的。

而在文件上传的场景中,如果对用户拖入文件夹展开,以实现对文件逐个上传,往往也是必须的(文件拖入监听的在线demo):

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="robots" content="noindex">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
  <style id="jsbin-css">
    h2 {
      border-bottom: solid 1px #eee;
      font-size: 16px;
      padding: 10px;
    }
    #container {
      border: solid 1px #eee;
      padding: 10px;
      background: #f9fdff;
    }
    #container:empty:after {
      content: '试试从磁盘拖拽文件或文件夹到这里';
      color: #aaa;
      font-size: 14px;
    }
    .item {
      line-height: 22px;
      font-size: 14px;
      margin: 10px 0;
      border-bottom: 1px solid #ccc;
    }
  </style>
</head>
<body>
  <h2>文件列表:</h2>
  <div id="container"></div>
  <script>
    document.addEventListener('drop', (e) => {
      e.stopPropagation();
      e.preventDefault();
      const entrys = items2Entrys(e.dataTransfer.items);
      getFileListByEntrys(entrys).then((fileList) => {
        container.innerHTML = fileList.map((file, i) => {
          const { fullPath, type, name, size } = file;
          return `
            <div class="item">
              序号: ${i}; <br />
              path: ${fullPath || name}; <br />
              type: ${type}; <br />
              size: ${size}
            </div>
          `;
        }).join('');
      });
    });

    document.addEventListener('dragover', (e) => {
      e.stopPropagation();
      e.preventDefault();
    });

    // 获取 fileList
    async function getFileListByEntrys(entrys) {
      const fileList = [];
      await Promise.all(
        [...entrys].map(async (entry) => {
          if (entry.isDirectory) {
            // 子文件夹内容
            const subEntries = await unfoldDirectory(entry);
            // 递归展开
            const subItems = await getFileListByEntrys(subEntries);
            fileList.push(...subItems);
          } else {
            // 读取文件信息
            await new Promise((resolve) => {
              const errCbk = (err) => {
                console.warn(err);
                resolve();
              };
              try {
                entry.file((file) => {
                  if (file) {
                    file.fullPath = entry.fullPath;
                    fileList.push(file);
                  }
                  resolve();
                }, errCbk);
              } catch (err) {
                errCbk(err);
              }
            });
          }
        })
      );
      return fileList;
    }

    // DataTransferItem 转 FileSystemEntry
    function items2Entrys(items) {
      return [...items].map((item) => {
        return (
          (item.getAsEntry && item.getAsEntry()) ||
          (item.webkitGetAsEntry && item.webkitGetAsEntry())
        );
      }).filter((item) => !!item);
    }

    // 展开文件列表
    function unfoldDirectory(item) {
      return new Promise((resolve) => {
        item.createReader().readEntries(resolve, () => resolve([]));
      });
    }
  </script>
</body>
</html>

而更多的场景可能还需要记录下文件所处文件夹信息,也可以基于上面代码自己试着修改实现一下,怎么还原出拖拽内容的树状目录结构。

从网页中拖出文本内容

按惯例,从简入繁,后面介绍文件拖拽前,我们先试验一下文本的拖拽应用。

在网页里创建个<span />,为其增加 draggable="true" 配置为可拖拽的元素,然后再监听其被拖拽的行为事件,并写一段自定义数据到 dataTransfer中。

大致代码如下(自定义文本内容拖拽DEMO):

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>可拖拽内容页</title>
  <style>
    body {
      padding: 50px;
    }
    #draggable {
      border: solid 1px #ccc;
      padding: 10px 20px;
    }
  </style>
</head>
<body>
  <span id="draggable" draggable="true">可拖拽元素块</span>
  <script>
    const el = document.getElementById('draggable');
    el.addEventListener('dragstart', (e) => {
      e.dataTransfer.setData(
        'text',
        '这是一段拖拽内容 wakakaka~ '
      );
    });
  </script>
</body>
</html>

效果截图:

alt

这时候,就可以通过拖拽“可拖拽文本”将自定义内容拖拽到任意三方APP里了,比如拖拽到一个本地文本编辑器中正编辑内容中的话,你就会看到放入位置多出了我们设置的 '这是一段拖拽内容 wakakaka~ ' 文案内容。

需要注意的是:如果拖拽文本内容到系统磁盘或桌面,可以得到一个扩展名为textClipping、内容为自定义文本内容的文本文件。

这有时候并不是想要的,比如拖拽一个商品到购物车的应用场景。

如果又刚好是单页应用内部的拖拽(非垮页、垮应用交互),可能放弃使用 dataTransfer.getData,而选择走内存变量的方式更符合应用场景(这不会干扰浏览器或WebView原有默认拖拽行为)。

网页中接收拖入的文本内容

当然,我们自然可以实现一个接受拖入文案的网页,来对上面场景的文本拖拽做相关应用,大致代码实现和“网页中接收文件拖入类似”,不同的是只用来读取数据。

也可以通过打开 网页中接收拖入文本内容Demo 来在线试验:

document.addEventListener('drop', (e) => {
  e.preventDefault();
  const text = e.dataTransfer.getData('text');
  document.body.innerText = `[${+new Date()}] 您拖入了内容:${text}`;
});
document.addEventListener('dragover', (e) => {
  e.preventDefault();
});

alt

这里为了方便演示垮应用数据分享,将“从网页中拖出文本内容”、“网页中接收拖入的文本内容”分成了两个独立网页来分别实现。

如果是单页应用内的交互能力,当然也可以放到一个页面里去实现。

查看拖入网页的各种内容数据

综合上面各种交互能力,为了方便观察拖拽数据内容。

类似之前剪切板内容读取Demo ,我们也先实现一个拖拽内容读取Demo工具

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="robots" content="noindex">
  <meta name="viewport" content="width=device-width">
  <title>拖拽dataTransfer内容读取试验</title>
  <style id="jsbin-css">
    h2 {
      border-bottom: solid 1px #eee;
      font-size: 16px;
      padding: 10px;
    }
    #container {
      border: solid 1px #eee;
      padding: 10px;
      background: #f9fdff;
    }
    #container:empty:after {
      content: '试试拖拽任意内容到当前页面中看看。';
      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%;
    }
    p {
      margin: -10px -10px 0;
      display: block;
      background: #d4f2ff;
      padding: 10px;
      border-bottom: solid 1px #cde0e9;
      text-shadow: 1px 1px 0px #fff;
    }
  </style>
</head>
<body>
  <h2>拖拽dataTransfer内容:</h2>
  <div id="container"></div>
  <script>
    document.addEventListener('drop', (evt) => {
      evt.preventDefault();
      container.innerHTML = '';
      const items = Array.from((evt.dataTransfer || {}).items || []);
      const countEl = document.createElement('p');
      countEl.innerText = 'event.dataTransfer.items.length: ' + items.length;
      container.appendChild(countEl);
      items.forEach((item, i) => {

        const { kind, type } = item;
        const entry = item.webkitGetAsEntry && item.webkitGetAsEntry();
        const isDirectory = entry && entry.isDirectory;

        const hdEl = document.createElement('h3');
        hdEl.innerText = `序号:${i};kind:${kind}; type:${type};`;
        container.appendChild(hdEl);

        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);
      });
    });
    document.addEventListener('dragover', (e) => {
      e.preventDefault();
    });
  </script>
</body>
</html>

现在如果从磁盘上拖入以下内容到上面网页:

alt

大致能看到如下读取到的文件或目录信息:

alt

单次拖出多种类型数据

前面我们只试验了单次拖出一段文本内容,实际上现有API能力已经允许单次拖拽得到多种不同类型的数据,到达目标APP后再自行选择要使用的数据。

有点类似从Excel复制一段表格内容,能得到截图、文本、HTML数据内容。

具体实现可以点这里在线试验"单次拖出多种类型数据",也可以参考下面代码对前面的文本数据拖拽进行修改:

const el = document.getElementById('draggable');
el.addEventListener('dragstart', (e) => {
  const { items } = e.dataTransfer;
  items.add('text plain test', 'text/plain');
  items.add("<p>... html test ...</p>", "text/html");
  items.add("https://blog.pyzy.net","text/uri-list");
  items.add("hzj custom content","custom-hzj");
});

设置拖拽图标

如果你有留意API文档,会发现还提供了一个 dataTransfer.setDragImage 方法。 可以用来设置拖拽内容时跟随鼠标的图标。

另外,为了对比试验,我们在页面里使用一个img元素,来对比一下浏览器原生图片拖拽与通过JS API setDragImage 的差异(打开试验图片文件拖拽):

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="robots" content="noindex">
  <meta name="viewport" content="width=device-width">
  <title>图片拖拽</title>
  <style>
    body {
      padding: 50px;
    }
    #draggable {
      border: solid 1px #ccc;
      padding: 10px 20px;
    }
  </style>
</head>
<body>
  <span
    id="draggable"
    draggable="true"
  >
    可拖拽元素块
  </span>
  <br /><br/>
  <h3>原生IMG标签</h3>
  <img id="img" src="https://p4.ssl.qhimg.com/t0157fa323b319adac4.png" />
  <script>
    const el = document.getElementById('draggable');
    el.addEventListener('dragstart', (e) => {
      e.dataTransfer.setDragImage(img, 20, 20);
    });
  </script>
</body>
</html>

这里值得注意的是,设置拖拽图和单纯图片文件拖拽不是一回事;这一点可能会比较容易混淆。

其次,原生img元素拖拽时,实际也是HTMLElement元素的拖拽,目标接收者能得到图片资源对应的 text/uri-list、以及图片的outerHTML text/html 两种类型的具体数据,并没能得到预期的File数据。

从网页中拖出文件

前文中从网页中拖出文本使用的是dataTransfer.setData(...args),这个方法接收的两个参数均为字符串,也就是只能往dataTransfer里写字符串。

但我们想直接让用户从网页上拖拽一个真正的文件(比如一个 new File(...args)对象)到磁盘或桌面,基于现有API是否能实现呢?

我们看到 event.dataTransfer.items 是有为前端开发者暴露了一个 add 方法的, 对应MDN文档DataTransferItemList.add(...args)

File 拖出

不妨来创建个网页拖拽文件Demo试验一下自定义 File 拖出的思路:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>可拖拽文件</title>
  <style>
    body {
      padding: 50px;
    }
    #draggable {
      border: solid 1px #ccc;
      padding: 10px 20px;
    }
  </style>
</head>
<body>
  <span id="draggable" draggable="true">可拖拽元素块</span>
  <script>
    const el = document.getElementById('draggable');
    el.addEventListener('dragstart', (e) => {
      const file = new File(['text content'], 'filename.txt', {
         type: 'text/plain',
      });
      e.dataTransfer.items.add(file);
    });
  </script>
</body>
</html>

可以拖拽到前文拖拽内容读取Demo工具中看看数据内容。

经试验你会发现,目前(2021-11-25)还是无法达成预期的文件拖拽的:其表现和个别浏览器中剪切板复制粘贴文件类似,只能拿到文件名...

文件拖出结论

虽然看起来API设计上允许 add一些FileDataTransferItemListsetDragImage来写入图片,但还是跟理想的“文件拖拽”的预期结果存在巨大差异;目前来看,也就没有靠谱方案基于浏览器端 JS API 直接实现文件拖出了。

但不妨发散一下脑洞,如果是被拖出和接收拖入的目标APP都是可控范围内的,也许可以 dataTransfer.setData('my-cus-type', DataURL) 的方案,通过自定义字符串数据的类型和内容来自行解决。

如果刚好是Electron中的网页应用,那就方便的多了,可以通过原生文件拖拽相关API,达成Electron场景中的网页内拖拽文件给三方APP或桌面的能力。

拖拽下载

另外,想要拖拽触发浏览器的下载并将文件存储到磁盘目标位置,还有下面这个非标准方案:

<a id="btnDownload" draggable="true">拖拽下载</a>
<script>
const TYPE = 'image/png';
const NAME = 'name.png';
const URL = 'https://example.com/tmpname.ext';
btnDownload.onDragStart = (evt) => {
    event.dataTransfer.setData('DownloadURL', `${TYPE}:${NAME}:${URL}`);
};
</script>

写在最后

首先,感谢您的阅读关注。

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

另外,相关API能力也是日新月异,本文的方案只是基于时下的形式考虑,如果发现不符欢迎随时指正,以免误导。

有更具体和拓展思路的应用场景,欢迎联系作者补充进来,一起拓展Web前端的能力边界,达成更多实用的业务能力。

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

-- EOF --

Comments

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