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。我问了利皮斯,是什么促使他投入这么多时间来研究《王者史》:

和其他人一样,我从 Christopher 的推文中了解到这个项目。我的首次贡献 是添加了 Open Color library,仍然是 今天是 Excalidraw 的一部分。随着项目的扩展,收到了很多请求,我的下一个大项目 构建了一个用于存储绘图的后端,以便用户可以共享这些绘图。但 尝试 Excalidraw 的人都希望能找到使用它的借口 。

我完全同意 lipis。尝试 Excalidraw 的人都想找借口再次使用它。

Excalidraw 的实际应用

现在,我想向大家展示如何在实践中使用 Excalidraw。我不是一位伟大的艺术家,但是 Google I/O 徽标太简单了,让我试一试。方框是“i”,线条可以是 正斜线“o”是一个圆形。按住 shift 可得到完美圆。让我动起来 所以它看起来更美观现在为“i”添加一些颜色和“o”。蓝色很好。不确定 采用不同的填充样式?全能型,还是交叉型?不,Hachure 看起来很棒。虽然这并不完美, 这就是 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 类型和扩展,下一步是以编程方式点击输入框 元素,以显示文件打开对话框。更改时(即用户选择了一个或 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;
};

“另存为”功能

如果我决定忽略现有的文件句柄,则可以实现“另存为”要创建的功能 以便在现有文件的基础上生成新文件为了演示这一点,让我打开一个现有文件 修改,但不覆盖现有文件,而是使用“Save-as” 功能。原文件将保持不变。

针对不支持 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 文件,它会立即打开,然后我可以开始编辑。在浏览器中 我甚至可以立即保存更改无需离开 因为已通过拖放操作获取所需的文件句柄 操作。

实现这一点的秘诀是调用 getAsFileSystemHandle() 数据传输项(如果 File System Access API 受支持)。然后,我会传递 文件句柄添加到 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 应用马上就会打开。你之前已经认识里皮斯,她们都是创作者 和 Excalidraw Electron 的弃用者。我问他为什么认为可以弃用 Electron 版本:

从一开始,人们就一直在寻找 Electron 应用,主要是因为他们想要 通过双击打开文件。我们还打算将应用发布到应用商店中。与此同时 我们建议创建 PWA,因此我们就两者都做了。幸运的是,我们了解了 Project Fugu 文件系统访问、剪贴板访问、文件处理等 API。只需点击一下,您就可以 在桌面设备或移动设备上安装应用,而无需 Electron 的额外重量。很简单 决定弃用 Electron 版本,只专注于 Web 应用, 最佳 PWA。最重要的是,我们现在能够将 PWA 发布到 Play 商店和 Microsoft 商店!这个超大!

可以说,用于 Electron 的 Excalidraw 没有被弃用,因为 Electron 坏了,一点也不好, 因为网络已经足够好了我喜欢!

文件处理

当我说“网络已经变得足够好”的时候,要归功于即将推出的 处理。

这是常规的 macOS Big Sur 安装方式。现在看看我右键点击 Excalidraw 文件。我可以选择使用已安装的 PWA 打开 Excalidraw。当然 同样,在抓屏中演示的意义就没那么大了。

那么,它是如何运作的呢?第一步是将我的应用能够处理的文件类型告知 操作系统我在 Web 应用清单中名为 file_handlers 的新字段中执行此操作。其 value 是包含操作和 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”的字段,该字段为我提供要使用的文件句柄数组。我只关心 首先是这个文件句柄 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 });
      });
    });
}

同样,如果速度太快,您可以在 我的文章。您可以通过设置实验性 Web 平台来启用文件处理功能 。该应用已计划于今年晚些时候在 Chrome 中推出。

剪贴板集成

Excalidraw 的另一个很酷的功能是剪贴板集成。我可以复制整个绘图 只是将图片的一部分粘贴到剪贴板中,如果您愿意,还可以加上水印,然后将其粘贴到 另一个应用。顺便提一下,这是 Windows 95 的“画图”应用网页版。

其运作方式出人意料地简单。我只需要将画布设为 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 上的 Google I/O 徽标在本地模拟了协作会议, Pixel 3a 手机和 iPad Pro。您可以看到我在一台设备上所做的更改会反映在 所有其他设备。

我甚至可以看到所有光标在四处移动。由于 Pixelbook 受控,光标会平稳移动 但 Pixel 3a 手机的光标和 iPad Pro 的平板电脑光标跳动了 就能通过用手指点按来控制这些设备

查看协作者状态

为了改善实时协作体验,甚至还有一个空闲的检测系统正在运行。 当我使用 iPad Pro 时,光标会显示绿点。当我切换到 其他浏览器标签页或应用当我打开了 Excalidraw 应用程序,但不执行任何操作时, 光标将我显示为空闲,用三个 zZZ 表示。

我们的发布内容的忠实读者可能倾向于认为,通过 Idle Detection API,这是一个早期阶段的提案,已在 Project Fugu 的相关文档。提前剧透:不是。虽然我们已有基于此 API 的实现 最后,我们决定采用一种更传统的方法, 指针移动和页面可见性。

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

我们提交了反馈,说明了为何要使用 Idle Detection API 并不能解决我们的应用场景所有 Project Fugu API 都是在开放环境中开发的,因此 每个人都可以参与进来,发表自己的意见!

利皮说什么阻止了 Excalidraw

说到这个,我向 Lipis 提出了最后一个问题:他认为网络中缺少什么内容 阻碍 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 大会的后续内容。