前言
前阵子接到一个开发任务,是复制网页上的图片,并粘贴到的聊天工具中。这是一个很常见的操作,在日常工作中我们经常通过 QQ 截图、 Ctrl + C
将图片复制到聊天框。
github 上几个高 star 的 js 复制库,比如 clipboard.js、copy-to-clipboard 等,其实现原理都是选中元素之后,调用 document.exceCommand('copy')
这个 API 实现复制,但是通常只支持文本复制,而且 API 已经过时了。因此,我决定封装一个小巧的组件,在 React 中实现图片复制。
接收剪贴板内容
虽然我们并不需要实现 剪贴板->粘贴
的过程,但是为了理清 复制->剪贴板
需要的数据,我们可以简单了解下输入框是如何接收数据的?接收的又是什么类型的数据?
- 基本原理是通过监听
paste
event,拦截到clipboardData
的数据(注意paste
事件只能在contenteditable: true
的元素上触发) - 再通过
reader.readAsDataURL
实现生成预览图片,或者直接上传图片数据到后端
// 代码参考:https://cloud.tencent.com/document/product/269/37448
document
.getElementById("testPasteInput")
.addEventListener("paste", function (e) {
let clipboardData = e.clipboardData;
let file;
if (
clipboardData &&
clipboardData.files &&
clipboardData.files.length > 0
) {
file = clipboardData.files[0];
var reader = new FileReader();
reader.onload = function (e) {
var image = document.getElementById("result");
image.src = e.target.result;
image.style.display = "block";
};
reader.readAsDataURL(file);
}
if (typeof file === "undefined") {
console.warn("file 是 undefined,请检查代码或浏览器兼容性!");
return;
}
});
通过代码,我们了解到,我们在输入框通过监听事件接收到来自剪贴板的 File/Blob 类型的内容。
了解了消息接收的原理之后,我们要解决的就是如何将目标内容加到剪贴板中。
目前可用的主要有两个 API:
excecommand
Clipboard API
使用 excecommand 复制内容到剪贴板
选中元素
使用 excecommand('copy')
的关键是,我们要实现选中元素的效果。说到选中,我们通常会想起 input 的 select()
事件,但并不是所有元素都有这个接口。一个更加通用的 API 是 Range API。我们可以利用这个 API 选中页面中的元素或者动态生成一个元素并选中。
function selectFakeInput(input: HTMLELEMENT) {
let selection = window.getSelection();
let range = document.createRange();
range.selectNode(input);
selection!.removeAllRanges();
selection!.addRange(range);
return () => {
selection!.removeAllRanges();
};
}
写入剪贴板
let success = document.execCommand("copy");
if (!success) {
throw Error("复制失败!");
}
实践发现这个 API 实现文本复制的效果较好,图片复制通常会失败
使用 Clipboard API 复制内容到剪贴板
将目标内容转化为 Blob 类型
//text
function textToBlob(target: string = "") {
return new Blob([target], { type: "text/plain" });
}
// image src(图片源需可跨域)
function imageToBlob(target: string = "") {
const response = await fetch(target);
return await response.blob();
}
询问浏览器是否支持
async function isSupportClipboardWrite() {
try {
const permission = await navigator.permissions?.query?.({
//@ts-ignore
name: "clipboard-write",
allowWithoutGesture: false,
});
return permission?.state === "granted";
} catch (error) {
return false;
}
}
写入剪贴板
const data = [new window.ClipboardItem({ [blob.type]: blob })];
await navigator.clipboard.write(data);
React 组件的封装
这里,我们尝试使用 hooks 封装。
export const useCopyImage = (props: ClipboardImageHooksProps) => {
const [status, setStatus] = useState<ChangeStatus>(null);
const [err, setErr] = useState<Error>(null);
const copyImage = useCallback(async (target: ImageCopyTarget) => {
try {
setErr(null);
setStatus("loading");
const canWrite = await isSupportClipboardWrite();
if (
!canWrite ||
!window.ClipboardItem ||
!navigator.clipboard?.write
) {
throw Error("broswer not supported!");
}
const blob = await imageToBlob(target);
const data = [new window.ClipboardItem({ [blob.type]: blob })];
await navigator.clipboard.write(data);
setStatus("done");
} catch (err) {
setStatus("error");
setErr(err);
}
}, []);
return { status, error: err, copy:copyImage };
};
封装后可直接使用
import { useCopyText } from "rc-clipboard-copy";
const App = () => {
const { copy, error, status } = useCopyText({});
return (
<div>
<button onClick={() => copy("hello word 2")}>copy text</button>
</div>
);
};
总结
详细的实现见 github 仓库