复制与粘贴
复制粘贴分两种需求,做法完全不同:
- 画布内原地复制(Ctrl/⌘ + D):直接
node.clone(),不经过剪贴板; - 复制到剪贴板再粘贴(Ctrl/⌘ + C / V):能跨标签页、跨刷新, 要把节点序列化成文本写进系统剪贴板,粘贴时再还原。
用法
选中图形后按 Ctrl/⌘ + C、Ctrl/⌘ + V;也可以在别的应用里复制一张图片,切回来直接粘贴:
/**
* editor/Clipboard.html
*/
<!DOCTYPE html>
<html>
<head>
<script src="https://unpkg.com/konva@10/konva.min.js"></script>
<meta charset="utf-8">
<title>Konva Copy And Paste Demo</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
background-color: #F0F0F0;
}
</style>
</head>
<body>
<div id="container"></div>
<script>
var FONT = '"PingFang SC", "Microsoft YaHei", sans-serif';
// 剪贴板里的内容带上这个标记,粘贴时据此判断是不是自己写进去的
var CLIPBOARD_TYPE = 'konva-zh-demo/nodes';
var stage = new Konva.Stage({
container: 'container',
width: window.innerWidth,
height: window.innerHeight
});
var layer = new Konva.Layer();
stage.add(layer);
var uid = 0;
function nextId() {
uid += 1;
return 'node-' + uid;
}
layer.add(new Konva.Rect({
id: nextId(), name: 'item', x: 40, y: 90, width: 90, height: 70,
fill: '#60a5fa', cornerRadius: 6, draggable: true
}));
layer.add(new Konva.Star({
id: nextId(), name: 'item', x: 220, y: 125, numPoints: 5,
innerRadius: 20, outerRadius: 42, fill: '#fbbf24', draggable: true
}));
// 图片节点:image 属性是 DOM 对象,toObject() 不会序列化它,
// 所以另外用自定义属性 src 记下地址,还原时按地址重新加载
function loadImageNode(node) {
var img = new window.Image();
img.onload = function () { node.image(img); };
img.src = node.getAttr('src');
}
var lion = new Konva.Image({
id: nextId(), name: 'item', x: 300, y: 80, width: 90, height: 90, draggable: true
});
lion.setAttr('src', '/assets/lion.png');
loadImageNode(lion);
layer.add(lion);
var tr = new Konva.Transformer();
layer.add(tr);
var info = new Konva.Text({
x: 20, y: 16, fontSize: 13, fill: '#333', fontFamily: FONT, lineHeight: 1.5,
text: '点选图形后:Ctrl/⌘ + C 复制、Ctrl/⌘ + V 粘贴、Ctrl/⌘ + D 原地复制。\n也可以从别处复制一张图片,直接粘贴到画布上。'
});
var clipStatus = new Konva.Text({
x: 20, y: 56, fontSize: 13, fill: '#2563eb', fontFamily: FONT, text: '剪贴板:空'
});
layer.add(info, clipStatus);
stage.on('click tap', function (e) {
if (e.target === stage) return tr.nodes([]);
if (e.target.hasName('item')) tr.nodes([e.target]);
});
// 页面上真正的输入框里的复制粘贴属于输入框,不属于画布
function isFormField(el) {
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
}
// ---- 复制 ----
// 系统剪贴板不一定可写,也不是每个浏览器都会在没有选中文字时派发 copy 事件,
// 页面里再存一份
var memory = null;
var pasteCount = 0;
function serialize(nodes) {
return JSON.stringify({
type: CLIPBOARD_TYPE,
nodes: nodes.map(function (n) { return n.toObject(); })
});
}
document.addEventListener('copy', function (e) {
var nodes = tr.nodes();
if (!nodes.length || isFormField(document.activeElement)) return;
var data = serialize(nodes);
// copy 事件里同步写入,不需要剪贴板权限,HTTP 页面也可用
e.clipboardData.setData('text/plain', data);
e.preventDefault();
memory = data;
pasteCount = 0;
clipStatus.text('剪贴板:' + nodes.length + ' 个节点');
});
// ---- 粘贴 ----
function restore(json) {
var parsed;
try { parsed = JSON.parse(json); } catch (err) { return false; }
if (!parsed || parsed.type !== CLIPBOARD_TYPE) return false;
pasteCount += 1;
var offset = 20 * pasteCount;
var created = parsed.nodes.map(function (obj) {
var node = Konva.Node.create(obj);
// id 必须重新生成:find('#id') 只返回第一个,重复的 id 会让选择错乱
node.id(nextId());
node.position({ x: node.x() + offset, y: node.y() + offset });
if (node.getAttr('src')) loadImageNode(node);
layer.add(node);
return node;
});
tr.moveToTop();
tr.nodes(created);
return true;
}
function pasteImageFile(file) {
var url = URL.createObjectURL(file);
var node = new Konva.Image({
id: nextId(), name: 'item', x: 60, y: 190, draggable: true
});
// object URL 只在当前页面有效;要跨页面复制,应先上传拿到正式地址
node.setAttr('src', url);
var img = new window.Image();
img.onload = function () {
var scale = Math.min(1, 160 / img.width, 160 / img.height);
node.setAttrs({ image: img, width: img.width * scale, height: img.height * scale });
tr.nodes([node]);
};
img.src = url;
layer.add(node);
tr.moveToTop();
}
var pasteHandled = false;
document.addEventListener('paste', function (e) {
if (isFormField(document.activeElement)) return;
pasteHandled = true;
// 先认自己写进去的 JSON:同时带有 PNG 的剪贴板内容,粘贴回来应是可编辑的节点
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
if (restore(text)) {
e.preventDefault();
clipStatus.text('剪贴板:已粘贴 ' + tr.nodes().length + ' 个节点');
return;
}
var items = e.clipboardData ? e.clipboardData.items : [];
for (var i = 0; i < items.length; i++) {
if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0) {
e.preventDefault();
pasteImageFile(items[i].getAsFile());
clipStatus.text('剪贴板:已粘贴一张系统图片');
return;
}
}
// 剪贴板里什么都没有(比如写入失败),退回页面内的副本
if (!text && restore(memory || '')) {
e.preventDefault();
clipStatus.text('剪贴板:已粘贴 ' + tr.nodes().length + ' 个节点');
}
});
window.addEventListener('keydown', function (e) {
if (e.isComposing || isFormField(e.target)) return;
var mod = e.metaKey || e.ctrlKey;
var key = e.key.toLowerCase();
// ---- 兜底:浏览器没有派发 copy / paste 事件时,用页面内的副本 ----
if (mod && key === 'c' && tr.nodes().length) {
memory = serialize(tr.nodes());
pasteCount = 0;
clipStatus.text('剪贴板:' + tr.nodes().length + ' 个节点');
}
if (mod && key === 'v') {
pasteHandled = false;
// paste 事件若会到达,一定在这个定时器之前
setTimeout(function () {
if (!pasteHandled && restore(memory || '')) {
clipStatus.text('剪贴板:已粘贴 ' + tr.nodes().length + ' 个节点');
}
}, 0);
}
// ---- 原地复制:画布内部直接 clone,不经过剪贴板 ----
if (mod && key === 'd' && tr.nodes().length) {
e.preventDefault(); // 否则浏览器会弹出「添加书签」
var copies = tr.nodes().map(function (n) {
var c = n.clone({ id: nextId(), x: n.x() + 20, y: n.y() + 20 });
layer.add(c);
return c;
});
tr.moveToTop();
tr.nodes(copies);
clipStatus.text('原地复制了 ' + copies.length + ' 个节点');
}
});
</script>
</body>
</html>
演示没有调用 navigator.clipboard,而是监听 document 的 copy 与 paste 事件。
用户按下复制粘贴快捷键时,浏览器会派发这两个事件,并在事件对象上提供 clipboardData,
在回调里同步读写即可:不需要申请权限,不弹确认框,HTTP 页面也能用。
两点细节。第一,焦点在页面的输入框里时,复制粘贴属于输入框,两个回调开头都要先判断
document.activeElement,否则用户在属性面板里复制一段文字,拿到的却是节点 JSON。
第二,并不是所有浏览器都会在页面没有选中文字时派发 copy / paste 事件——
本站只在 Chrome 上实测了事件路径。演示因此在 keydown 里另存一份页面内的副本:
按下 Ctrl/⌘ + V 后如果 paste 事件没有到达,就用这份副本粘贴。
序列化用 node.toObject(),还原用 Konva.Node.create(obj)。写进剪贴板的 JSON 里加一个
type 字段作标记:用户剪贴板里可能是任何文字,粘贴时先确认是自己写进去的,再去还原。
navigator.clipboard.writeText() 留给没有快捷键的场景,比如右键菜单里的「复制」按钮。
点击按钮不会触发 copy 事件,只能调用这个异步接口,它的限制见下文。
图片节点要单独处理
toObject() 会跳过所有「不是普通对象」的属性。Konva.Image 的 image 属性是一个
HTMLImageElement,不会被序列化;还原出来的节点尺寸、位置都对,就是一片空白。
做法是创建图片节点时用自定义属性记下地址,还原后按地址重新加载:
const node = new Konva.Image({ x: 0, y: 0, width: 90, height: 90 });
node.setAttr('src', '/assets/lion.png'); // 自定义属性会正常进入 toObject() 的结果
function loadImageNode(node) {
const img = new window.Image();
img.onload = () => node.image(img);
img.src = node.getAttr('src');
}
同样会丢失的还有函数形式的滤镜:filters 属性只保留字符串形式的 CSS 滤镜
(如 'blur(4px)'),Konva.Filters.Blur 这类函数会被过滤掉,
需要的话也要像 src 一样另存滤镜名,还原时再映射回函数。
这些规则对整个舞台的序列化同样成立,见舞台序列化。
从系统剪贴板粘贴进来的图片,演示里用 URL.createObjectURL() 生成地址。这种地址只在当前页面有效,
刷新或换一个标签页就失效了。要支持跨页面复制,应该先把图片上传到服务器,拿到正式地址再写进 src。
国内环境注意事项
navigator.clipboard 只在安全上下文里存在,也就是 HTTPS 页面或 localhost。
很多公司内网的后台系统仍是 HTTP 部署,在这些页面里 navigator.clipboard 直接是 undefined,
调用就会报错。这也是演示优先用 copy / paste 事件的原因:它们不受这个限制。
读取剪贴板的 navigator.clipboard.readText() 限制更多:Chrome 会弹出权限请求,
Safari 会在页面上弹出一个「粘贴」确认按钮,部分国产浏览器和 App 内置浏览器则直接拒绝。
所以粘贴一律走 paste 事件,不要为了「点击按钮粘贴」去主动读剪贴板。
手机上情况又不同:没有键盘快捷键,copy / paste 事件基本不会发生,
App 内置浏览器里跨应用粘贴图片也很难做到。移动端的编辑器应该另外提供「复制」「粘贴」按钮
(页面内部用内存变量中转即可),以及一个「上传图片」入口。
常见问题
粘贴出来的图片是空白的?
Konva.Image 的 image 属性是 DOM 对象,toObject() 不会序列化它,
还原出来的节点没有图片。创建时用 node.setAttr('src', url) 记下地址,
还原后按 src 重新加载,写法见上文。
粘贴后 id 重复了怎么办?
Konva.Node.create() 会原样还原 id,粘贴一次就有两个 id 相同的节点。
stage.findOne('#id') 只返回第一个,依赖 id 的选择、撤销、同步逻辑都会出错。
还原后立刻给每个新节点重新赋 id;粘贴的是 Group 的话,要用 group.find('*') 连同子节点一起改。
复制的节点能粘贴到 Figma 或 PPT 里吗?
不能直接粘贴,这些软件不认识 Konva 的 JSON。要互通的话,复制时把 JSON 和一份 PNG
写进同一个 ClipboardItem——分两次写入的话,后一次会把前一次覆盖掉:
const png = await node.toBlob({ pixelRatio: 2 });
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([json], { type: 'text/plain' }),
'image/png': png,
}),
]);
粘贴到别的软件里得到的是图片;粘贴回自己的画布时,paste 回调要先检查 JSON 标记、再检查图片文件,
才能还原成可编辑的节点,演示正是这个顺序。注意 navigator.clipboard.write 受安全上下文限制,见上一节。