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 应用,提供离线支持和出色的深色模式,而且可以借助 File System Access API 打开和保存文件。

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

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 类型和文件扩展名后,核心部分是调用 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

您可以使用我尚未介绍过的文件进行 doubleclick 操作。当您双击某个文件时,通常会打开与该文件的 MIME 类型相关联的应用。例如,.docx 为 Microsoft Word。

Excalidraw 过去曾经拥有支持此类文件类型关联的 Electron 版本,因此当您双击 .excalidraw 文件时,Excalidraw Electron 应用就会打开。您之前已认识过 Lipis,他们既是 Excalidraw Electron 的创建者,又是 Excalidraw Electron 的弃用者。我问他为什么认为可以弃用 Electron 版本:

从一开始,用户就一直在索要 Electron 应用,主要是因为他们想通过双击打开文件。我们还打算将该应用发布到应用商店。与此同时,有人建议改为创建 PWA,因此我们同时进行了这两项工作。幸运的是,我们了解了 Project Fugu API,例如文件系统访问、剪贴板访问、文件处理等。只需点击一下,即可在桌面设备或移动设备上安装该应用,而无需额外安装 Electron。我们很快便决定弃用 Electron 版本,专注于 Web 应用,并使其成为最佳的 PWA。此外,我们现在还可以将 PWA 发布到 Play 商店和 Microsoft 商店!太棒了!

有人可能会说,适用于 Electron 的 Excalidraw 没有被弃用,因为 Electron 很差,一点也不好,但 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 });
      });
    });
}

同样,如果速度太快,您可以参阅我的文章,详细了解 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 实现的。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 标签页。然后,我可以在这个新标签页中打开辅助文件,并在同一个应用窗口中独立处理这些文件。

标签页式应用模式尚处于早期阶段,并非所有内容都是一成不变的。如果您有兴趣,请务必阅读我的文章,了解此功能的当前状态。

Closing

如需随时了解此功能和其他功能,请务必观看我们的 Fugu API 跟踪器。我们非常高兴能够推动网络向前发展 让您可以在平台上实现更多目标在此向不断改进的 Excalidraw 致敬,向您将构建的所有卓越应用致敬。前往 excalidraw.com 开始创作。

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