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

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

我如何接触到 Excalidraw

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

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

1 月 15 日,Christopher 发布了一篇博文,在 Twitter 上引起了广泛关注,我也注意到了。帖子开头列出了一些令人印象深刻的统计数据:

  • 1.2 万名活跃用户
  • 在 GitHub 上获得 1.5K 星
  • 26 位贡献者

对于一个两周前才启动的项目来说,这已经非常不错了。但真正引起我兴趣的是帖子更后面的内容。Christopher 写道,他这次尝试了一些新做法:为每个提交拉取请求的人提供无条件提交权限。在阅读博文的当天,我提出了一个拉取请求,为 Excalidraw 添加了文件系统访问 API 支持,从而修复了有人提交的功能请求。

我宣布 PR 的推文的屏幕截图。

一天后,我的拉取请求被合并,从那时起,我获得了完整的提交权限。不用说,我没有滥用权力。迄今为止,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 属性的值为所需文件名,href 属性的值为 blob 网址。

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,因此我们同时创建了 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 });
      });
    });
}

同样,如果这部分内容讲得太快,您可以阅读我的文章,详细了解 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);
    }
  });
};

与其他人协作

共享会话网址

您知道吗?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 的实现,但最终我们决定采用更传统的方法,即测量指针移动和页面可见性。

在 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 集成。 文件系统、文件处理、剪贴板、Web 共享和 Web 共享目标。但还有一件事。到目前为止,我一次只能编辑一个文档。现在不需要了。欢迎首次体验 Excalidraw 中标签页式应用模式的早期版本。其外观如下。

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

标签页式应用模式尚处于早期阶段,一切都尚未定型。如果您感兴趣,请务必在我的文章中阅读有关此功能当前状态的信息。

结束语

如需及时了解此功能和其他功能,请务必关注我们的 Fugu API 跟踪器。我们非常高兴能够推动 Web 发展, 让您在平台上实现更多功能。祝愿 Excalidraw 越来越好,也祝愿您能构建出各种精彩的应用。前往 excalidraw.com 开始创作。

我迫不及待地想看到今天展示的一些 API 出现在您的应用中。我叫 Tom,您可以在 Twitter 和互联网上通过 @tomayac 找到我。 非常感谢您的观看,祝您在 Google I/O 大会中收获满满。