Excalidraw 和 Fugu:改进核心用户旅程

任何足够先进的技术都与魔法无异。除非你理解它。我叫 Thomas Steiner,在 Google 的开发者关系部门工作。在我这篇 Google I/O 演讲的稿件中,我将介绍一些新的 Fugu API,以及它们如何改进 Excalidraw PWA 中的核心用户旅程,以便您可以从这些想法中获得启发并将它们应用到您自己的应用中。

我是如何接触 Excalidraw 的

我想从一个故事开始讲起。2020 年 1 月 1 日,Facebook 的软件工程师 Christopher Chedeau 发推文 介绍了他开始开发的一个小型绘图应用。使用这个工具,您可以绘制卡通风格的手绘框和箭头。第二天,您还可以绘制椭圆和文本,以及选择对象并移动它们。1 月 3 日,该应用有了名字,Excalidraw,并且,像所有好的副项目一样,购买 域名 是 Christopher 的首要行动之一。到现在,您可以使用颜色并将整个绘图导出为 PNG。

Screenshot of the Excalidraw prototype application showing that it supported rectangles, arrows, ellipses, and text.

1 月 15 日,Christopher 发表了一篇 博文,在 Twitter 上引起了很多关注,包括我的关注。这篇文章开头列举了一些令人印象深刻的数据:

  • 12K 独立活跃用户
  • GitHub 上 1.5K 个星标
  • 26 位贡献者

对于一个仅仅在两周前启动的项目来说,这已经相当不错了。但真正引起我兴趣的是文章的后面部分。Christopher 写道,这次他尝试了一些新的东西:给予每个提交拉取请求的人无条件的提交权限。 在阅读这篇博文的同一天,我提交了一个 拉取请求,为 Excalidraw 添加了 File System Access API 支持,修复了某人提出的 功能请求

Screenshot of the tweet where I announce my PR.

我的拉取请求在一天后被合并,从那时起,我就拥有了完全的提交权限。不用说,我没有滥用我的权力。到目前为止,149 位贡献者中的任何一位也没有。

今天,Excalidraw 是一款功能齐全的可安装渐进式 Web 应用,具有离线支持、令人惊叹的暗黑模式,是的,还具有借助 File System Access API 打开和保存文件的能力。

Screenshot of the Excalidraw PWA in today's state.

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 的理念,所以让我保存它。

我点击保存图标,并在文件保存对话框中输入文件名。在 Chrome 浏览器中,一个支持 File System Access API 的浏览器,这并不是下载,而是一个真正的保存操作,我可以选择文件的位置和名称,并且,如果我进行编辑,我可以将它们保存到同一个文件中。

让我更改徽标,将 “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 类型和扩展名之后,下一步是以编程方式点击 input 元素,以便显示文件打开对话框。在更改时,即当用户选择了一个或多个文件时,Promise 会 resolve。

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 URL 作为其 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 URL 需要在使用后撤销。由于这只是一个下载,因此永远不会显示文件保存对话框,并且所有文件都将落在默认的 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 商店!这太棒了!

可以说,Excalidraw for Electron 被弃用不是因为 Electron 不好,根本不是,而是因为 Web 已经变得足够好。我喜欢这样!

文件处理

当我说“Web 已经变得足够好”时,是因为像即将推出的文件处理这样的功能。

这是一个常规的 macOS Big Sur 安装。现在看看当我右键单击 Excalidraw 文件时会发生什么。我可以选择使用 Excalidraw(已安装的 PWA)打开它。当然,双击也可以,只是在截屏视频中演示不太戏剧化。

那么这是如何工作的呢?第一步是让操作系统知道我的应用程序可以处理的文件类型。我在 Web 应用清单中的一个名为 file_handlers 的新字段中执行此操作。它的值是一个对象数组,其中包含一个操作和一个 accept 属性。操作确定操作系统启动您的应用程序时所处的 URL 路径,而 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() 来设置使用者。此函数的参数是一个异步函数,该函数接收 launchParamslaunchParams 对象有一个名为 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 画图应用的 Web 版本。

这种工作方式出奇地简单。我所需要的只是画布作为 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);
    }
  });
};

与他人协作

共享会话 URL

您知道 Excalidraw 也具有协作模式吗?不同的人可以共同处理同一文档。要开始一个新会话,我点击实时协作按钮,然后开始会话。借助 Excalidraw 集成的 Web Share API,我可以轻松地与我的协作者共享会话 URL。

实时协作

我通过在我的 Pixelbook、我的 Pixel 3a 手机和我的 iPad Pro 上处理 Google I/O 徽标,在本地模拟了一个协作会话。您可以看到我在一台设备上所做的更改会反映在所有其他设备上。

我甚至可以看到所有光标都在移动。Pixelbook 的光标移动平稳,因为它由触控板控制,但 Pixel 3a 手机的光标和 iPad Pro 的平板电脑光标会跳动,因为我通过用手指点击来控制这些设备。

查看协作者状态

为了改善实时协作体验,甚至还有一个空闲检测系统在运行。当我使用 iPad Pro 的光标时,它会显示一个绿点。当我切换到不同的浏览器标签页或应用时,该点会变为黑色。当我在 Excalidraw 应用中,但只是什么都不做时,光标会显示我处于空闲状态,用三个 zZZ 表示。

我们出版物的狂热读者可能会倾向于认为空闲检测是通过 Idle Detection API 实现的,这是一个早期阶段的提案,已在 Project Fugu 的背景下进行开发。剧透警告:事实并非如此。虽然我们在 Excalidraw 中有一个基于此 API 的实现,但最终,我们决定采用一种更传统的方法,该方法基于测量指针移动和页面可见性。

Screenshot of the Idle Detection feedback filed on the WICG Idle Detection repo.

我们就 Idle Detection API 为何没有解决我们遇到的用例提交了 反馈。所有 Project Fugu API 都在公开开发中,因此每个人都可以参与进来并发出自己的声音!

Lipis 谈论是什么阻碍了 Excalidraw

说到这里,我问了 lipis 最后一个问题,关于他认为 Web 平台缺少什么阻碍了 Excalidraw

File System Access API 很棒,但您知道吗?这些天我关心的文件大多存在我的 Dropbox 或 Google Drive 中,而不是我的硬盘上。我希望 File System Access API 能够包含一个远程文件系统提供商(如 Dropbox 或 Google)集成的抽象层,并且开发人员可以针对该抽象层进行编码。用户可以放心,并且知道他们的文件在他们信任的云提供商那里是安全的。

我完全同意 lipis 的观点,我也生活在云端。希望这能尽快实现。

标签式应用程序模式

哇!我们已经看到了 Excalidraw 中许多非常棒的 API 集成。文件系统文件处理剪贴板Web ShareWeb Share Target。但还有一件事。到目前为止,我一次只能编辑一个文档。现在不一样了。请首次体验 Excalidraw 中标签式应用程序模式的早期版本。它的外观如下。

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

标签式应用程序模式尚处于早期阶段,并非一切都已成定局。如果您有兴趣,请务必阅读 我的文章,了解此功能的当前状态。

结束语

要及时了解此功能和其他功能,请务必关注我们的 Fugu API 跟踪器。我们非常高兴能够推动 Web 向前发展,并让您在该平台上做更多事情。祝愿 Excalidraw 越来越好,也祝愿所有您将构建的精彩应用。立即访问 excalidraw.com 开始创作。

我迫不及待想看到我今天展示的一些 API 出现在您的应用中。我叫 Tom,您可以在 Twitter 和互联网上通过 @tomayac 找到我。非常感谢您的观看,祝您在 Google I/O 的剩余时间里玩得开心。