任何足够先进的技术都与魔法无异。除非您了解该功能。我叫 Thomas Steiner,在 Google 的开发者关系部门工作。在这篇 Google I/O 演讲稿中,我将介绍一些新的 Fugu API,以及它们如何改进 Excalidraw PWA 中的核心用户体验,以便您从这些想法中获得灵感,并将其应用到自己的应用中。
我如何接触到 Excalidraw 的
我想先讲个故事。2020 年 1 月 1 日,Facebook 的软件工程师 Christopher Chedeau 在 Twitter 上发推文,介绍了他开始开发的一款小型绘图应用。借助此工具,您可以绘制卡通风格的手绘框和箭头。第二天,您还可以绘制椭圆和文字,以及选择对象并将其四处移动。1 月 3 日,该应用正式命名为 Excalidraw,与所有优秀的副项目一样,购买域名是 Christopher 最先采取的行动之一。现在,您可以使用颜色并将整个绘图导出为 PNG。
1 月 15 日,Christopher 发布了一篇博文,在 Twitter 上引起了广泛关注,包括我。该帖子开头列出了一些令人印象深刻的统计数据:
- 1.2 万个唯一身份活跃用户
- GitHub 上有 1,500 颗星
- 26 位贡献者
对于仅启动了两周的项目来说,这已经相当不错了。不过,真正让我兴趣大增的是帖子中更靠下的内容。Christopher 写道,他这次尝试了新做法:向每个提交了拉取请求的人员授予无条件的提交访问权限。在阅读该博文的当天,我提交了一个拉取请求,该请求为 Excalidraw 添加了文件系统访问 API 支持,从而修复了某人提交的功能请求。
我的拉取请求在一天后被合并,从那时起,我就拥有了完整的提交访问权限。毋庸置疑,我没有滥用权力。到目前为止,149 位贡献者中也没有任何人这样做。
如今,Excalidraw 是一款功能全面的可安装渐进式 Web 应用,支持离线使用,提供出色的深色模式,并且能够借助 File System Access API 打开和保存文件。
Lipis 谈论了他为何将如此多的时间投入到 Excalidraw 中
我的“我如何接触到 Excalidraw”的故事就此结束了,但在深入了解 Excalidraw 的一些出色功能之前,我很荣幸能介绍一下 Panayiotis。Panayiotis Lipiridis 在网上被称为 lipis,是 Excalidraw 最多产的贡献者。我问了 lipis,是什么促使他将如此多的时间投入到 Excalidraw 中:
和其他人一样,我是通过 Christopher 的推文了解到这个项目的。我的第一个贡献是添加了 Open Color 库,这些颜色至今仍是 Excalidraw 的一部分。随着项目的发展,我们收到了很多请求,我的下一个重大贡献是构建了一个用于存储绘画的后端,以便用户可以分享绘画。但真正促使我做出贡献的是,尝试过 Excalidraw 的人都在寻找再次使用它的理由。
我完全同意 lipis 的观点。尝试过 Excalidraw 的人都在寻找再次使用它的理由。
Excalidraw 实际应用
现在,我想向您展示如何在实践中使用 Excalidraw。我不是一位出色的艺术家,但 Google I/O 徽标足够简单,所以让我尝试一下。方框是“i”,线条可以是斜线,“o”是圆圈。我按住 Shift 键,这样就能画出完美的圆形。我稍微调整一下斜线的位置,这样看起来会更好。现在为“i”和“o”添加一些颜色。蓝色表示良好。或许可以尝试其他填充样式?全实线还是交叉影线?不,等高线阴影效果很棒。虽然它并不完美,但这就是 Excalidraw 的理念,因此我决定保存它。
我点击保存图标,然后在文件保存对话框中输入文件名。在支持 File System Access API 的浏览器 Chrome 中,这并不是下载,而是一项真正的保存操作,我可以选择文件的位置和名称,如果进行修改,只需将其保存到同一文件中即可。
让我更改徽标,并将“i”改为红色。如果我现在再次点击“保存”,我的修改会保存到与之前相同的文件中。为了证明这一点,我将清除画布并重新打开文件。如您所见,修改后的红蓝徽标再次出现。
使用文件
在目前不支持 File System Access API 的浏览器上,每次保存操作都是一次下载,因此当我进行更改时,最终会得到多个文件名中包含递增数字的文件,这些文件会填满我的“下载内容”文件夹。不过,尽管有这个缺点,我仍然可以保存文件。
打开文件
那么秘诀是什么呢?在可能支持或可能不支持 File System Access API 的不同浏览器中,如何实现打开和保存功能?在 Excalidraw 中打开文件是在名为 loadFromJSON)(
的函数中进行的,该函数会调用名为 fileOpen()
的函数。
export const loadFromJSON = async (localAppState: AppState) => {
const blob = await fileOpen({
description: 'Excalidraw files',
extensions: ['.json', '.excalidraw', '.png', '.svg'],
mimeTypes: ['application/json', 'image/png', 'image/svg+xml'],
});
return loadFromBlob(blob, localAppState);
};
fileOpen()
函数来自我编写的一个名为 browser-fs-access 的小型库,我们在 Excalidraw 中使用该库。此库通过 File System Access API 提供文件系统访问权限,并提供旧版回退,因此可在任何浏览器中使用。
我们先来看看支持该 API 的实现。在协商好接受的 MIME 类型和文件扩展名后,核心部分是调用文件系统访问 API 的函数 showOpenFilePicker()
。此函数会返回一个文件数组或单个文件,具体取决于是否选择了多个文件。剩下的就是将文件句柄放在文件对象上,以便再次检索。
export default async (options = {}) => {
const accept = {};
// Not shown: deal with extensions and MIME types.
const handleOrHandles = await window.showOpenFilePicker({
types: [
{
description: options.description || '',
accept: accept,
},
],
multiple: options.multiple || false,
});
const files = await Promise.all(handleOrHandles.map(getFileWithHandle));
if (options.multiple) return files;
return files[0];
const getFileWithHandle = async (handle) => {
const file = await handle.getFile();
file.handle = handle;
return file;
};
};
后备实现依赖于类型为 "file"
的 input
元素。在协商要接受的 MIME 类型和扩展名之后,下一步是以编程方式点击输入元素,以便显示文件打开对话框。当用户选择一个或多个文件时,该 promise 会解析。
export default async (options = {}) => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
const accept = [
...(options.mimeTypes ? options.mimeTypes : []),
options.extensions ? options.extensions : [],
].join();
input.multiple = options.multiple || false;
input.accept = accept || '*/*';
input.addEventListener('change', () => {
resolve(input.multiple ? Array.from(input.files) : input.files[0]);
});
input.click();
});
};
保存文件
现在,我们来了解如何节省费用。在 Excalidraw 中,保存操作在名为 saveAsJSON()
的函数中进行。它首先将 Excalidraw 元素数组序列化为 JSON,将 JSON 转换为 blob,然后调用名为 fileSave()
的函数。此函数同样由 browser-fs-access 库提供。
export const saveAsJSON = async (
elements: readonly ExcalidrawElement[],
appState: AppState,
) => {
const serialized = serializeAsJSON(elements, appState);
const blob = new Blob([serialized], {
type: 'application/vnd.excalidraw+json',
});
const fileHandle = await fileSave(
blob,
{
fileName: appState.name,
description: 'Excalidraw file',
extensions: ['.excalidraw'],
},
appState.fileHandle,
);
return { fileHandle };
};
我们先来看看支持 File System Access API 的浏览器的实现。前几行代码看起来有点复杂,但它们所做的只是协商 MIME 类型和文件扩展名。如果之前已保存过,并且已有文件句柄,则无需显示保存对话框。但如果是首次保存,系统会显示文件对话框,并返回一个文件句柄供应用日后使用。其余部分只是写入文件,这通过可写数据流完成。
export default async (blob, options = {}, handle = null) => {
options.fileName = options.fileName || 'Untitled';
const accept = {};
// Not shown: deal with extensions and MIME types.
handle =
handle ||
(await window.showSaveFilePicker({
suggestedName: options.fileName,
types: [
{
description: options.description || '',
accept: accept,
},
],
}));
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return handle;
};
“另存为”功能
如果我决定忽略已存在的文件句柄,我可以实现“另存为”功能,以基于现有文件创建新文件。为了演示这一点,我将打开一个现有文件,进行一些修改,然后不覆盖现有文件,而是使用“另存为”功能创建一个新文件。这样一来,原始文件将保持不变。
不支持 File System Access API 的浏览器的实现非常简单,因为它所做的只是创建一个具有 download
属性的锚元素,该属性的值是所需的文件名,并使用 Blob 网址作为其 href
属性值。
export default async (blob, options = {}) => {
const a = document.createElement('a');
a.download = options.fileName || 'Untitled';
a.href = URL.createObjectURL(blob);
a.addEventListener('click', () => {
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
a.click();
};
然后,以编程方式点击锚元素。为防止内存泄漏,Blob 网址需要在使用后撤消。由于这只是下载,因此系统不会显示任何文件保存对话框,所有文件都会下载到默认的 Downloads
文件夹中。
拖放
在桌面设备上,我最喜欢的系统集成功能之一是拖放。在 Excalidraw 中,当我将 .excalidraw
文件拖放到应用上时,该文件会立即打开,我可以开始编辑。在支持 File System Access API 的浏览器上,我甚至可以立即保存更改。由于已通过拖放操作获取所需的文件句柄,因此无需打开文件保存对话框。
实现此目的的秘诀是在支持 File System Access API 时,对数据传输项调用 getAsFileSystemHandle()
。然后,我将此文件句柄传递给 loadFromBlob()
,您可能还记得,我们在上文的几个段落中提到过它。您可以对文件执行许多操作:打开、保存、覆盖保存、拖动、放置。我和同事 Pete 在这篇文章中记录了所有这些技巧以及更多内容,以便您在觉得这些内容有点太快时可以跟上进度。
const file = event.dataTransfer?.files[0];
if (file?.type === 'application/json' || file?.name.endsWith('.excalidraw')) {
this.setState({ isLoading: true });
// Provided by browser-fs-access.
if (supported) {
try {
const item = event.dataTransfer.items[0];
file as any.handle = await item as any
.getAsFileSystemHandle();
} catch (error) {
console.warn(error.name, error.message);
}
}
loadFromBlob(file, this.state).then(({ elements, appState }) =>
// Load from blob
).catch((error) => {
this.setState({ isLoading: false, errorMessage: error.message });
});
}
分享文件
目前在 Android、ChromeOS 和 Windows 上实现的另一项系统集成是通过 Web Share Target API 完成的。现在,我位于“文件”应用中的 Downloads
文件夹内。我可以看到两个文件,其中一个文件的名称为 untitled
,并包含时间戳。为了查看其内容,我点击了三个点状图标,然后点击“分享”,随即显示了“Excalidraw”选项。当我点按该图标时,会看到该文件仅包含 I/O 徽标。
Lipis 在已弃用的 Electron 版本上
我还没介绍过,您还可以双击文件。双击文件时,通常会打开与该文件的 MIME 类型关联的应用。例如,对于 .docx
,这会是 Microsoft Word。
Excalidraw 曾经有一个 Electron 版本的应用支持此类文件类型关联,因此当您双击 .excalidraw
文件时,Excalidraw Electron 应用会打开。您之前已经见过 Lipis,他是 Excalidraw Electron 的创建者和弃用者。我问他为什么认为可以弃用 Electron 版本:
自一开始,人们就一直要求提供 Electron 应用,主要是因为他们希望通过双击打开文件。我们还打算将该应用上架到应用商店。与此同时,有人建议改为创建 PWA,因此我们同时做了这两件事。幸运的是,我们了解了 Project Fugu API,例如文件系统访问、剪贴板访问、文件处理等。只需点击一下,您就可以在桌面设备或移动设备上安装该应用,而无需 Electron 的额外负担。因此,我们决定弃用 Electron 版本,专注于 Web 应用,并将其打造为尽可能出色的 PWA。此外,我们现在还能够将 PWA 发布到 Play 商店和 Microsoft Store!这太棒了!
可以说,Excalidraw for Electron 的弃用并不是因为 Electron 不好,而是因为 Web 已经足够好。我喜欢这个!
文件处理
我说“Web 已经足够好”是因为即将推出的文件处理等功能。
这是常规的 macOS Big Sur 安装。现在,我们来看看右键点击 Excalidraw 文件时会发生什么。我可以选择使用已安装的 PWA Excalidraw 打开该文件。当然,双击也可以,只是在屏幕截图中演示效果不太明显。
那么,这种方式是如何运作的呢?第一步是让操作系统知道我的应用可以处理哪些文件类型。我在 Web 应用清单中添加了一个名为 file_handlers
的新字段来实现此目的。其值是一个对象数组,包含操作和 accept
属性。该操作决定了操作系统启动应用时所用的网址路径,而 accept 对象是 MIME 类型和关联文件扩展名的键值对。
{
"name": "Excalidraw",
"description": "Excalidraw is a whiteboard tool...",
"start_url": "/",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff",
"file_handlers": [
{
"action": "/",
"accept": {
"application/vnd.excalidraw+json": [".excalidraw"]
}
}
]
}
下一步是在应用启动时处理文件。这种情况发生在 launchQueue
接口中,我需要通过调用 setConsumer()
来设置消费者。此函数的形参是一个异步函数,用于接收 launchParams
。此 launchParams
对象有一个名为“files”的字段,该字段可获取一个文件句柄数组以供使用。我只关心第一个,并从该文件句柄中获取一个 blob,然后将其传递给我们的老朋友 loadFromBlob()
。
if ('launchQueue' in window && 'LaunchParams' in window) {
window as any.launchQueue
.setConsumer(async (launchParams: { files: any[] }) => {
if (!launchParams.files.length) return;
const fileHandle = launchParams.files[0];
const blob: Blob = await fileHandle.getFile();
blob.handle = fileHandle;
loadFromBlob(blob, this.state).then(({ elements, appState }) =>
// Initialize app state.
).catch((error) => {
this.setState({ isLoading: false, errorMessage: error.message });
});
});
}
同样,如果您觉得速度太快,可以阅读我的文章,详细了解文件处理 API。您可以通过设置实验性 Web 平台功能标志来启用文件处理。该功能计划于今年晚些时候在 Chrome 中推出。
剪贴板集成
Excalidraw 的另一项出色功能是剪贴板集成。我可以将整个绘图或部分绘图复制到剪贴板,还可以根据需要添加水印,然后将其粘贴到其他应用中。顺便说一下,这是 Windows 95 画图应用的 Web 版。
这种方式的运作方式出人意料地简单。我只需要将画布作为 blob,然后通过将包含 blob 的 ClipboardItem
的单元素数组传递给 navigator.clipboard.write()
函数,将该 blob 写入剪贴板。如需详细了解剪贴板 API 的用途,请参阅 Jason 和我的文章。
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new window.ClipboardItem({
'image/png': blob,
}),
]);
};
export const canvasToBlob = async (canvas: HTMLCanvasElement): Promise<Blob> => {
return new Promise((resolve, reject) => {
try {
canvas.toBlob((blob) => {
if (!blob) {
return reject(new CanvasError(t('canvasError.canvasTooBig'), 'CANVAS_POSSIBLY_TOO_BIG'));
}
resolve(blob);
});
} catch (error) {
reject(error);
}
});
};
与其他人协作
分享会话网址
您知道吗?Excalidraw 还提供协作模式。不同的人可以共同处理同一份文档。若要开始新会话,我点击实时协作按钮,然后开始会话。得益于 Excalidraw 集成的 Web Share API,我可以轻松与协作者分享会话网址。
实时协作
我通过在 Pixelbook、Pixel 3a 手机和 iPad Pro 上处理 Google I/O 徽标,在本地模拟了一次协作会话。您可以看到,我在一台设备上所做的更改会反映在所有其他设备上。
我甚至可以看到所有光标的移动。Pixelbook 的光标移动平稳,因为它由触控板控制;但 Pixel 3a 手机的光标和 iPad Pro 的平板电脑光标会跳动,因为我通过手指点按来控制这些设备。
查看协作者状态
为了改善实时协作体验,甚至还运行着一个空闲检测系统。 使用 iPad Pro 时,光标会显示一个绿点。当我切换到其他浏览器标签页或应用时,圆点会变成黑色。当我在 Excalidraw 应用中但未执行任何操作时,光标会显示我处于空闲状态,以三个 zZZ 表示。
我们出版物的忠实读者可能会认为,空闲检测是通过 Idle Detection API 实现的,这是在 Fugu 项目背景下开发的一项早期提案。剧透预警:事实并非如此。虽然我们在 Excalidraw 中实现了基于此 API 的功能,但最终我们决定采用一种更传统的方法,即基于测量指针移动和页面可见性。
我们提交了反馈,说明了 Idle Detection API 未能解决我们遇到的使用情形。所有 Project Fugu API 都在公开开发中,因此每个人都可以参与其中并发表自己的意见!
Lipis 谈论了阻碍 Excalidraw 发展的因素
说到这里,我向 lipis 提出了最后一个问题,询问他认为 Web 平台缺少哪些功能,从而阻碍了 Excalidraw 的发展:
File System Access API 非常棒,但您知道吗?如今,我关注的大部分文件都存储在 Dropbox 或 Google 云端硬盘中,而不是我的硬盘上。我希望文件系统访问 API 包含一个用于远程文件系统提供商(如 Dropbox 或 Google)进行集成的抽象层,以便开发者可以针对该抽象层进行编码。这样一来,用户就可以放心了,因为他们知道自己的文件在信任的云服务提供商那里是安全的。
我完全同意 lipis 的观点,我也生活在云端。希望此功能很快就能实现。
标签页式应用模式
哇!我们看到 Excalidraw 中有很多非常出色的 API 集成。 文件系统、文件处理、剪贴板、Web Share 和 Web Share Target。不过,还有一件事。在此之前,我一次只能编辑一个文档。现在不需要了。欢迎首次体验 Excalidraw 中标签页式应用模式的早期版本。其外观如下。
我已在以独立模式运行的已安装 Excalidraw PWA 中打开了一个现有文件。现在,我在独立窗口中打开一个新标签页。这不是常规浏览器标签页,而是 PWA 标签页。在这个新标签页中,我可以打开辅助文件,并在同一应用窗口中独立处理这两个文件。
标签页式应用模式尚处于早期阶段,尚未完全定型。如果您感兴趣,请务必阅读我的文章,了解此功能的当前状态。
结束语
如需及时了解此功能和其他功能,请务必关注我们的 Fugu API 跟踪器。我们非常高兴能推动网络发展,让您在 YouTube 上实现更多功能。祝愿 Excalidraw 不断改进,也祝愿您构建出各种出色的应用。前往 excalidraw.com 开始创作。
我迫不及待地想看到您在应用中使用我今天展示的某些 API。我叫 Tom,您可以在 Twitter 和整个互联网上找到我,我的用户名是 @tomayac。 非常感谢您的观看,祝您在 Google I/O 大会上度过愉快的时光。