前言
我在前面一篇文章《记一个网页端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/plain
和text/html
两种类型的内容:
简化后的“通过对粘贴事件的监听读到剪切板中的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中Drag
、Drop
相关的API能力,早已经不是个新的话题了。
通过拖拽文件放置到网页中来达成上传、网页中通过拖拽商品放置购物车实现购买等等,很多具体应用场景,大家也许早就不知不觉中有过相关的交互体验。
而Drag
、Drop
又完全可以分开了单独使用。
网页中接收文件拖入
比如可以通过监听 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>
效果截图:
这时候,就可以通过拖拽“可拖拽文本”将自定义内容拖拽到任意三方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();
});
这里为了方便演示垮应用数据分享,将“从网页中拖出文本内容”、“网页中接收拖入的文本内容”分成了两个独立网页来分别实现。
如果是单页应用内的交互能力,当然也可以放到一个页面里去实现。
查看拖入网页的各种内容数据
综合上面各种交互能力,为了方便观察拖拽数据内容。
类似之前剪切板内容读取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>
现在如果从磁盘上拖入以下内容到上面网页:
大致能看到如下读取到的文件或目录信息:
单次拖出多种类型数据
前面我们只试验了单次拖出一段文本内容,实际上现有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
一些File
到DataTransferItemList
或setDragImage
来写入图片,但还是跟理想的“文件拖拽”的预期结果存在巨大差异;目前来看,也就没有靠谱方案基于浏览器端 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前端的能力边界,达成更多实用的业务能力。
Comments
可以发邮件 huzunjie@pyzy.net 或移步到 https://github.com/huzunjie/blog.pyzy.net/issues 评论交流。