Excalidraw 和 Fugu:改善核心用户历程

任何足够先进的技术都与魔法无异。除非您了解。我是 Google 开发者关系团队的 Thomas Steiner。在本次 Google I/O 大会演讲的文字记录中,我将介绍一些新的 Fugu API,以及它们如何改进 Excalidraw PWA 中的核心用户体验历程,以便您从这些想法中汲取灵感,并将其应用到您自己的应用中。

我如何接触到 Excalidraw

我想先讲一个故事。2020 年 1 月 1 日,Facebook 软件工程师 Christopher Chedeau推文中提到了他开始着手开发的一款小型绘图应用。借助此工具,您可以绘制看起来像卡通和手绘的框和箭头。第二天,您还可以绘制椭圆形和文本,以及选择对象并将其移动到其他位置。1 月 3 日,该应用有了自己的名字:Excalidraw。与所有优秀的副业项目一样,购买域名是 Christopher 首先要做的事。现在,您可以使用颜色并将整个绘制内容导出为 PNG 文件。

Excalidraw 原型应用的屏幕截图,显示该应用支持矩形、箭头、椭圆形和文本。

1 月 15 日,Christopher 发布了一篇博文,在 Twitter 上引起了广泛关注,包括我自己。该帖子开头就列出了一些令人印象深刻的数据:

  • 12,000 名活跃唯一身份用户
  • 在 GitHub 上获得 1.5 千颗星
  • 26 位贡献者

对于一个才刚刚开始两周的项目来说,这已经不错了。但真正引起我兴趣的是帖子下方的内容。Christopher 写道,这次他尝试了一些新做法:向提交了拉取请求的所有人授予无条件提交权限。在阅读该博文的当天,我就提交了一个拉取请求,为 Excalidraw 添加了对文件系统访问 API 的支持,从而修复了某位用户提交的功能请求

我宣布公共关系的推文的屏幕截图。

我的拉取请求在一天后合并,从那以后,我获得了完整的提交权限。不用说,我没有滥用职权。到目前为止,149 位贡献者中也没有其他人发现此问题。

如今,Excalidraw 是一款功能完善且可安装的渐进式 Web 应用,支持离线使用,具有出色的深色模式,并且借助文件系统访问 API,还可以打开和保存文件。

当前状态下的 Excalidraw PWA 的屏幕截图。

Lipis 讲述为何要花这么多时间使用 Excalidraw

至此,我的故事“我如何开始使用 Excalidraw”就告一段落了。不过,在深入介绍 Excalidraw 的部分出色功能之前,我很高兴能向大家介绍一下 Panayiotis。Panayiotis Lipiridis(在互联网上简称为 lipis)是 Exacalidraw 最活跃的贡献者。我问了 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,我们在 Exacalidraw 中使用该库。此库通过 File System Access API 提供文件系统访问权限,并提供旧版回退,因此可在任何浏览器中使用。

我先向您展示一下在支持该 API 时实现的方法。协商接受的 MIME 类型和文件扩展名后,核心部分是调用 File System Access 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 徽标。

已废弃的 Electron 版本中的 Lipis

我还没有提到,您可以对文件执行的一种操作是双击。当您双击文件时,通常会打开与文件的 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 商店!太棒了!

有人可能会说,之所以没有弃用适用于 Electron 的 Excalidraw,并不是因为 Electron 不好,而是因为 Web 已经足够好了。我喜欢这个!

文件处理

之所以说“Web 已经足够好”,是因为即将推出的文件处理等功能。

这是常规的 macOS Big Sur 安装。现在,请查看右键点击某个 Exacalidraw 文件时会发生什么情况。我可以选择使用已安装的 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 });
      });
    });
}

再次提醒一下,如果速度过快,您可以参阅我的这篇文章,详细了解 File Handling API。您可以通过设置实验性 Web 平台功能标志来启用文件处理功能。该功能计划于今年晚些时候在 Chrome 中推出。

剪贴板集成

Excalidraw 的另一项很酷的功能是剪贴板集成。我可以将整个绘图或其中的部分内容复制到剪贴板,根据需要添加水印,然后将其粘贴到其他应用中。顺便提一下,这是 Windows 95 Paint 应用的网页版。

其运作方式出奇地简单。我只需要将画布作为 Blob,然后通过将包含 Blob 的 ClipboardItem 的单元素数组传递给 navigator.clipboard.write() 函数,将其写入剪贴板。如需详细了解如何使用剪贴板 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 实现的,这是一个在 Project Fugu 背景下进行的早期提案。提前剧透:不是。虽然我们在 Excalidraw 中实现了基于此 API 的方法,但最终还是决定采用基于测量指针移动和页面可见度的更传统的方法。

在 WICG 空闲检测代码库中提交的空闲检测反馈的屏幕截图。

我们提交了反馈,说明了 Idle Detection API 为何无法解决我们的用例。所有 Project Fugu API 都是在公开环境中开发的,因此每个人都可以参与其中,表达自己的想法!

Lipis 谈 Excalidraw 的不足之处

说到这里,我又问了 lipis 最后一个问题,询问他认为 Web 平台缺少哪些功能,导致 Excalidraw 的表现不佳:

File System Access API 非常棒,但您知道吗?我现在关心的大多数文件都存储在 Dropbox 或 Google 云端硬盘中,而不是硬盘上。我希望 File System Access API 包含一个抽象层,供 Dropbox 或 Google 等远程文件系统提供程序集成,并供开发者编写代码。这样一来,用户就可以放心了,因为他们的文件在他们信任的云服务提供商处是安全的。

我完全同意 lipis 的说法,我也在云端工作。希望此功能能尽快实现。

标签页式应用模式

哇!我们在 Excalidraw 中看到了很多非常棒的 API 集成。文件系统文件处理剪贴板网页共享网页共享目标。不过,还有一件事。到目前为止,我每次只能编辑一个文档。现在不需要了。欢迎首次在 Excalidraw 中体验标签页式应用模式的早期版本。如下所示。

我已在安装的 Excalidraw PWA 中打开一个现有文件,该文件在独立模式下运行。现在,我在独立窗口中打开一个新标签页。这不是普通的浏览器标签页,而是 PWA 标签页。然后,我可以在这个新标签页中打开辅助文件,并在同一应用窗口中独立处理它们。

标签页式应用模式尚处于早期阶段,并非所有内容都已确定。如果您有兴趣,请务必阅读这篇文章,了解此功能的最新状态。

结束语

如需及时了解这项功能及其他功能的最新动态,请务必关注我们的 Fugu API 跟踪器。我们非常高兴能够推动 Web 技术的发展,让您能够在 YouTube 上实现更多功能。祝 Excalidraw 不断改进,也祝您构建出各种出色的应用。前往 excalidraw.com 开始创作。

我非常期待看到我今天介绍的部分 API 在您的应用中显示。我叫 Tom,您可以在 Twitter 和互联网上通过 @tomayac 找到我。非常感谢您的观看,祝您在 Google I/O 大会的余下时间里一切顺利。