课程 24 - 上下文菜单和剪贴板
在本节课中我们将介绍:
- 如何使用 Spectrum 实现上下文菜单
- 通过上移下移调整图形次序
- 写入并读取剪贴板内容,支持粘贴序列化图形、非矢量图片、SVG 和纯文本
- 从文件系统和页面中拖拽导入图片文件

实现上下文菜单
上下文菜单通常由右键或者长按交互触发。浏览器默认实现了菜单内容,例如在 <canvas>
上触发会展示 “Save as” 等等。 因此第一步我们要监听 contextmenu 事件并阻止浏览器默认行为,参考:radix - Context Menu。
private handleContextMenu = async (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// ...展示 Overlay
};
this.api.element.addEventListener('contextmenu', this.handleContextMenu);
接下来我们需要在指定位置展示菜单 UI 组件。课程 18 - 基于 Spectrum 实现 UI,我们使用 Overlay 的命令式 API,配合 Using a virtual trigger:
import { html, render } from '@spectrum-web-components/base';
import { VirtualTrigger, openOverlay } from '@spectrum-web-components/overlay';
private handleContextMenu = async (event: MouseEvent) => {
// ...阻止浏览器默认行为
// 在当前位置触发
const trigger = event.target as LitElement;
const virtualTrigger = new VirtualTrigger(event.clientX, event.clientY);
// 渲染 Lit 模版
const fragment = document.createDocumentFragment();
render(this.contextMenuTemplate(), fragment);
// 展示 Overlay
const popover = fragment.querySelector('sp-popover') as HTMLElement;
const overlay = await openOverlay(popover, {
trigger: virtualTrigger,
placement: 'right-start',
offset: 0,
notImmediatelyClosable: true,
type: 'auto',
});
trigger.insertAdjacentElement('afterend', overlay);
this.renderRoot.appendChild(overlay);
}
在下面的例子中,在选中图形上唤起上下文菜单后可以调整 z-index
。或者使用快捷键,在 Figma 中是 [ 和 ]。这里我参考了 Photoshop Web,使用 ⌘[ 和 ⌘]。
写入剪贴板
我们的目标是向剪贴板中写入序列化后的图形列表。用户可以通过两种方式触发这一行为:通过 copy 事件触发(例如 Ctrl+C);通过上下文菜单触发。我们先来看第一种情况,监听 copy 事件,这里的 passive 可以告知浏览器我们在事件处理中有可能调用 preventDefault
:
document.addEventListener('copy', this.handleCopy, { passive: false });
此时需要通过 activeElement
确保画布处于当前激活态,然后禁用浏览器默认行为并阻止事件冒泡:
private handleCopy = (event: ClipboardEvent) => {
const { layersSelected } = this.appState;
if (
document.activeElement !== this.api.element ||
layersSelected.length === 0
) {
return;
}
this.executeCopy(event); // 传递 ClipboardEvent
event.preventDefault();
event.stopPropagation();
};
在通过上下文菜单触发的场景下,并不存在 ClipboardEvent。参考 excalidraw clipboard 和 actionClipboard 的实现从新到旧依次尝试浏览器的 API:
export async function copyTextToClipboard(
text: string,
clipboardEvent?: ClipboardEvent,
) {
// 1.
await navigator.clipboard.writeText(text);
// 2.
if (clipboardEvent) {
clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text);
}
// 3.
document.execCommand('copy');
}
读取剪贴板
读取剪贴板的实现决定了我们支持哪些常见类型的文件,从 MIME 类型上包括:图片、文本。而文本又可能包含序列化图形、SVG、URL、甚至是 mermaid 语法等等。我们先从最简单的情况开始,接收上一节中序列化后的图形列表文本。
document.addEventListener('paste', this.handlePaste, { passive: false });
和写入剪贴板一样,先尝试 read 方法,该方法仅在 HTTPS 下生效,理论上支持所有类型数据,不局限于纯文本,几乎所有现代浏览器都支持了该方法,只是在数据类型上有所限制,通常包括文本、HTML 和图片。如果不支持该方法就降级到 readText
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
// 1.
const clipboardItems = await navigator.clipboard?.read();
// 2.
const readText = await navigator.clipboard?.readText();
if (readText) {
return { [MIME_TYPES.text]: readText };
}
};
另外我们也可以用这个方法判断此时剪贴板是否为空,如果为空就禁用 Paste
菜单项。最后需要注意的是读取剪贴板方法需要聚焦在文档上,如果触发时焦点在浏览器地址栏、开发者工具时,就会报以下错误。因此可以使用 document.hasFocus()
判断成功后再读取:
CAUTION
Uncaught (in promise) NotAllowedError: Failed to execute 'read' on 'Clipboard': Document is not focused
反序列化图形
反序列化后,我们只需要重新生成 id
:
if (data.elements) {
const nodes = data.elements.map((node) => {
node.id = uuidv4();
return node;
});
this.api.runAtNextTick(() => {
this.api.updateNodes(nodes);
this.api.record();
});
}
但这样复制后的图形会重叠在一起,我们可以采用以下两种策略:
- 跟随鼠标位置创建
- 在原有图形位置上增加一个偏移量
第二种比较简单:
const nodes = data.elements.map((node) => {
node.id = uuidv4();
node.x += 10;
node.y += 10;
return node;
});
而第一种需要记录上下文菜单触发时或鼠标最近一次的移动位置:
private handleContextMenu = async (event: MouseEvent) => {
this.lastContextMenuPosition = { x: event.clientX, y: event.clientY };
}
private handlePointerMove = (event: PointerEvent) => {
this.lastPointerMovePosition = { x: event.clientX, y: event.clientY };
};
我们已经介绍过:课程 6 - 坐标系转换,将 Client 坐标转换到 Canvas 坐标系下:
if (position) {
const { x, y } = api.viewport2Canvas(api.client2Viewport(position));
node.x = x;
node.y = y;
} else {
node.x += 10;
node.y += 10;
}
非矢量图片
先来看复制非矢量图片的情况。之前我们介绍过:课程 10 - 在画布中渲染图片,使用 @loaders.gl
可以加载剪贴板中的图片文件,然后得到原始宽高和 dataURL
,经过一系列调整计算得到最终的宽高,最后创建一个矩形将其 fill
设置为 dataURL
:
import { load } from '@loaders.gl/core';
import { ImageLoader } from '@loaders.gl/images';
async function createImage(api: ExtendedAPI, appState: AppState, file: File) {
const [image, dataURL] = await Promise.all([
load(file, ImageLoader),
getDataURL(file),
]);
// 省略计算宽高
updateAndSelectNodes(api, appState, [
{
id: uuidv4(),
type: 'rect',
x: position?.x ?? 0,
y: position?.y ?? 0,
width,
height,
fill: dataURL,
},
]);
}
为什么不能直接使用原始图片的宽高呢?创建的矩形宽高是在世界坐标系下的,它应该受画布尺寸、当前相机缩放等级和原始图片尺寸影响。Excalidraw 使用了一种启发式的算法:
// Heuristic to calculate the size of the image.
// @see https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/components/App.tsx#L10059
const minHeight = Math.max(canvas.height - 120, 160);
// max 65% of canvas height, clamped to <300px, vh - 120px>
const maxHeight = Math.min(
minHeight,
Math.floor(canvas.height * 0.5) / canvas.zoom,
);
const height = Math.min(image.height, maxHeight);
const width = height * (image.width / image.height);
SVG
在粘贴图片时,Excalidraw 并不会对 SVG 类型的图片进行特殊处理,这导致丧失了继续编辑其内部元素的可能。
Figma 可以将 SVG 元素转换成画布图形,并让其中的元素可编辑,详见:Convert SVG to frames。在 课程 10 - 从 SVGElement 到序列化节点 中我们介绍过这一方法,在此之前需要使用 DOMParser
将字符串转换成 SVGElement:
import {
DOMAdapter,
svgElementsToSerializedNodes,
} from '@infinite-canvas-tutorial/ecs';
if (data.text) {
const string = data.text.trim();
if (string.startsWith('<svg') && string.endsWith('</svg>')) {
const doc = DOMAdapter.get()
.getDOMParser()
.parseFromString(string, 'image/svg+xml');
const $svg = doc.documentElement;
const nodes = svgElementsToSerializedNodes(
Array.from($svg.children) as SVGElement[],
);
this.updateAndSelectNodes(nodes);
return;
}
}
当然你也可以使用 innerHTML
的方式,但不推荐用于复杂 SVG(可能会失去命名空间):
const container = document.createElement('div');
container.innerHTML = svgString.trim();
const svgElement = container.firstChild;
纯文本
最后一种情况也是最简单的,如果此时剪贴板中的内容是纯文本,我们就创建对应的 text
。值得一提的是在 Excalidraw 中会使用当前用户设置的字体、颜色等属性:
function createText(
api: ExtendedAPI,
appState: AppState,
text: string,
position?: { x: number; y: number },
) {
updateAndSelectNodes(api, appState, [
{
id: uuidv4(),
type: 'text',
anchorX: position?.x ?? 0,
anchorY: position?.y ?? 0,
content: text,
fontSize: 16,
fontFamily: 'system-ui',
fill: 'black',
},
]);
}
拖拽导入图片
很多文件上传组件都支持从文件管理器等位置拖拽文件到指定区域完成上传,例如 react-dropzone。Excalidraw 也支持 handleAppOnDrop,可以很方便地将图片、导出产物、甚至是视频拖入画布中完成导入:
<div onDrop={this.handleAppOnDrop} />
为了让 drop
事件能在 <canvas>
上正常触发,我们还需要监听 dragover
并禁止浏览器默认行为,详见:HTML5/Canvas onDrop event isn't firing? 和 Prevent the browser's default drag behavior
this.api.element.addEventListener('dragover', this.handleDragOver);
this.api.element.addEventListener('drop', this.handleDrop);
然后我们就可以从 files 尝试读取从文件系统拖拽来的文件了:
private handleDrop = async (event: DragEvent) => {
for (const file of Array.from(event.dataTransfer.files)) {}
}
另外我们也可以支持从页面中拖拽而来的文本和图片,文本可以直接从 dataTransfer
中读取。而对于图片,参考 Dragging Images,推荐将图片 URL 写入 dataTransfer
中。在下面的例子里我们在图片的 dragstart
中完成这一步:
const text = event.dataTransfer.getData('text/plain');
if (text) {
createText(this.api, this.appState, text, canvasPosition);
}
img.addEventListener('dragstart', (ev) => {
const dt = ev.dataTransfer;
dt?.setData('text/uri-list', img.src);
dt?.setData('text/plain', img.src);
});
可以在下面的例子中将页面中的文字或者右侧的图片直接拖拽进画布:

从文件系统上传
当然还有最传统的从文件系统上传方式:前端最熟悉的 <input type="file">
。这里我们参考 Excalidraw 的 filesystem 实现,使用 browser-fs-access 尝试使用更命令式的 File System API,如果浏览器不支持会自动降级。
if (pen === Pen.IMAGE) {
try {
const file = await fileOpen({
extensions: ['jpg', 'png', 'svg'],
description: 'Image to upload',
});
if (file) {
createImage(this.api, this.appState, file);
this.api.setAppState({ penbarSelected: Pen.SELECT });
this.api.record();
}
} catch (e) {
// 用户取消上传,退回选择模式
this.api.setAppState({ penbarSelected: Pen.SELECT });
}
}
可以点击左侧工具栏中的 “图片” 按钮体验这一功能。