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

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

我是怎么加入 Excalidraw

我想从故事开始。2020 年 1 月 1 日,Facebook 软件工程师 Christopher Chedeau 发推文,谈论他开始开发的一款小型绘图应用。借助此工具,您可以绘制卡通和手绘风格的方框和箭头。第二天,您还可以绘制省略号和文字,以及选择对象并四处移动它们。在 1 月 3 日,该应用的名称为 Excalidraw,而且与每个优秀的副项目一样,购买域名是 Christopher 的早期行动之一。现在,您可以使用颜色并将整个绘图导出为 PNG 文件。

Excalidraw 原型应用的屏幕截图,其中显示了支持矩形、箭头、省略号和文本。

1 月 15 日,Christopher 在 Twitter 上发表了一篇博文,吸引了大量关注,我也在其中。帖子的开头部分令人印象深刻:

  • 1.2 万位唯一身份活跃用户
  • GitHub 上的 1,500 星
  • 26 位贡献者

对于仅仅两周前就开始的项目来说,情况还不错。但最吸引我兴趣的 是帖子里的内容Christopher 写道,他这次尝试了一些新东西:为收到拉取请求的每个人提供无条件提交访问权限。在阅读这篇博文的当天,我提出一个拉取请求,要求为 Excalidraw 添加了 File System Access API 支持,从而修复了有人提交的功能请求

我宣布自己公共关系的 Twitter 微博的屏幕截图。

我的拉取请求一天后合并了,从此,我拥有了完整的提交权限。不用说,我没有滥用自己的能力到目前为止,这 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 的人都会为了找借口再次使用 Excalidraw。

我完全赞同 lipis 的观点。无论谁试过 Excalidraw,都会找借口再次使用它。

Excalidraw 的实际运用

现在我想向大家展示如何在实践中使用 Excalidraw。我不是一位优秀的艺术家,不过 Google I/O 大会徽标足够简单,让我试一试。方框代表“i”,线条可以是斜杠,“o”代表圆形。我按住 Shift 键,得到了一个正好圆。我稍微移动一下斜线,让它看起来更美观了。现在为“i”和“o”添加一些颜色。蓝色是很好的。也许要换一种填充样式?全是实体,还是交叉影线?不,哈奇看起来不错。这种做法并不完美 但就是 Excalidraw 的理念

点击“保存”图标,然后在文件保存对话框中输入文件名。在 Chrome 中,这是一款支持 File System Access API 的浏览器,它并非下载操作,而是真正的保存操作。我可以选择文件的位置和名称。如果我进行修改,只需将其保存到同一文件中即可。

下面我来修改一下徽标,将“i”改为红色。如果现在再次点击“Save”(保存),我所做的修改 会保存到之前一样的文件中为了证明,我先清除画布,然后重新打开该文件。可以看到,修改后的红/蓝徽标又出现了

使用文件

在目前不支持 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 的创造者,也是弃用者。我问他为什么认为可以弃用 Electron 版本:

从一开始,人们就一直在寻找 Electron 应用,主要是因为他们希望通过双击打开文件。我们还打算将应用放入应用商店。与此同时,有人建议改为创建 PWA,因此我们刚刚创建了这两项。幸好,我们介绍了 Project Fugu API,例如文件系统访问、剪贴板访问、文件处理等。只需点击一下,即可在桌面设备或移动设备上安装该应用,而无需承担 Electron 的额外负担。他很容易决定废弃 Electron 版本,只专注于 Web 应用,并使其成为最佳的 PWA。最重要的是,我们现在能够将 PWA 发布到 Play 商店和 Microsoft Store!太棒了!

可以说,Excalidraw for Electron 没有被废弃,因为 Electron 很差,完全不是因为网络已经足够好。我喜欢这样!

文件处理

我说“网络已经足够好”,这要归功于即将推出的文件处理等功能。

这是常规的 macOS Big Sur 安装。现在来看看右键点击 Excalidraw 文件会发生什么情况我可以选择使用已安装的 PWA 打开 Excalidraw。当然,双击也可以,只是在抓屏中展示不太明显。

那么,它是如何运作的呢?第一步是让操作系统知道我的应用可以处理的文件类型。我可以在 Web 应用清单中名为 file_handlers 的新字段中执行此操作。其值是具有操作和 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,我从这个文件句柄中获得一个 blob,然后我将该 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,然后我会将一个包含 ClipboardItem 的单元素数组及 blob 传递给 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 提出了最后一个问题,他认为阻碍 Excalidraw 的网络平台缺少什么:

File System Access API 非常强大,但您知道吗?最近我关注的大多数文件都 保存在我的 Dropbox 或 Google 云端硬盘中,而不是我的硬盘中我希望 File System Access API 能够包含一个抽象层,以供 Dropbox 或 Google 等远程文件系统提供商与之集成,并且开发者能够使用该层进行编码。这样,用户就可以放心地放心使用自己信任的云服务提供商提供的文件,从而放心地访问文件。

我完全赞同 lipis 的观点,我也是在云端。我们希望能够尽快实现这一功能。

标签页式应用模式

哇!我们在 Excalidraw 中看到了许多非常出色的 API 集成。文件系统文件处理剪贴板网络共享网络共享目标。不过,还有一件事。到目前为止,我每次只能编辑一个文档现在不会了。这是我们首次体验 Excalidraw 中标签式应用模式的早期版本。这就是它的外观。

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

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

正在关闭

如需随时了解此功能和其他功能,请务必观看我们的 Fugu API 跟踪器。我们非常高兴能够推动网络向前发展 并让您在这个平台上做得更多在此祝贺 Excalidraw 形势不断完善,我们将祝贺您将要构建的所有出色的应用。访问 excalidraw.com,开始创作。

我迫不及待地想看到我今天介绍的一些 API 会在您的应用中弹出。我叫 Tom,您可以在 Twitter 和互联网上找到我 @tomayac。非常感谢您的观看,希望 Google I/O 大会的后续内容能够顺利进行。