尝试以扩展 VS Code 中的代理模式!

自定义编辑器 API

自定义编辑器允许扩展创建完全可自定义的读/写编辑器,这些编辑器可用于特定类型的资源,以取代 VS Code 的标准文本编辑器。它们具有多种用例,例如:

  • 直接在 VS Code 中预览资产,例如着色器或 3D 模型。
  • 为 Markdown 或 XAML 等语言创建所见即所得编辑器。
  • 为 CSV、JSON 或 XML 等数据文件提供替代的可视化渲染。
  • 为二进制或文本文件构建完全可自定义的编辑体验。

本文档概述了自定义编辑器 API 以及实现自定义编辑器的基础知识。我们将研究两种类型的自定义编辑器及其区别,以及哪种类型适合您的用例。然后,对于每种自定义编辑器类型,我们将介绍构建一个行为良好的自定义编辑器的基础知识。

尽管自定义编辑器是一个强大的新扩展点,但实现一个基本的自定义编辑器实际上并不困难!不过,如果您正在开发您的第一个 VS Code 扩展,您可能需要考虑在更熟悉 VS Code API 的基础知识之后再深入研究自定义编辑器。自定义编辑器基于许多 VS Code 概念构建——例如 Webview 和文本文档——因此如果您同时学习所有这些新概念,可能会有点不知所措。

但是,如果您觉得准备好了,并且正在思考要构建的所有酷炫的自定义编辑器,那么让我们开始吧!请务必下载自定义编辑器扩展示例,这样您就可以跟着文档操作,并了解自定义编辑器 API 是如何协同工作的。

VS Code API 用法

自定义编辑器 API 基础

自定义编辑器是一种替代视图,用于替代 VS Code 的标准文本编辑器来显示特定资源。自定义编辑器分为两部分:用户交互的视图以及您的扩展用于与底层资源交互的文档模型。

自定义编辑器的视图侧是使用 Webview 实现的。这允许您使用标准的 HTML、CSS 和 JavaScript 构建自定义编辑器的用户界面。Webview 无法直接访问 VS Code API,但它们可以通过来回传递消息与扩展进行通信。有关 Webview 的更多信息以及使用它们的最佳实践,请查看我们的 Webview 文档

自定义编辑器的另一部分是文档模型。此模型是您的扩展理解其正在处理的资源(文件)的方式。`CustomTextEditorProvider` 使用 VS Code 的标准 TextDocument 作为其文档模型,并且对文件的所有更改都使用 VS Code 的标准文本编辑 API 表示。另一方面,`CustomReadonlyEditorProvider` 和 `CustomEditorProvider` 允许您提供自己的文档模型,这使它们可以用于非文本文件格式。

自定义编辑器每个资源只有一个文档模型,但此文档可以有多个编辑器实例(视图)。例如,假设您打开一个具有 `CustomTextEditorProvider` 的文件,然后运行 **View: Split editor** 命令。在这种情况下,仍然只有一个 `TextDocument`,因为工作区中仍然只有一个资源的副本,但现在该资源有两个 Webview。

CustomEditor vs CustomTextEditor

自定义编辑器有两种类型:自定义文本编辑器和自定义编辑器。它们之间的主要区别在于它们如何定义文档模型。

CustomTextEditorProvider 使用 VS Code 的标准 TextDocument 作为其数据模型。您可以将 CustomTextEditor 用于任何基于文本的文件类型。CustomTextEditor 的实现要简单得多,因为 VS Code 已经知道如何处理文本文件,因此可以实现保存和为热退出备份文件等操作。

另一方面,对于 CustomEditorProvider,您的扩展程序会带来自己的文档模型。这意味着您可以将 CustomEditor 用于图像等二进制格式,但这也意味着您的扩展程序要负责更多的事情,包括实现保存和备份。如果您的自定义编辑器是只读的,例如用于预览的自定义编辑器,则可以跳过大部分复杂性。

在决定使用哪种类型的自定义编辑器时,决定通常很简单:如果您正在处理基于文本的文件格式,请使用 CustomTextEditorProvider;对于二进制文件格式,请使用 CustomEditorProvider

贡献点

customEditors 贡献点 是您的扩展告诉 VS Code 它提供了哪些自定义编辑器的方式。例如,VS Code 需要知道您的自定义编辑器适用于哪些类型的文件,以及如何在任何 UI 中识别您的自定义编辑器。

以下是自定义编辑器扩展示例的基本 customEditor 贡献

"contributes": {
  "customEditors": [
    {
      "viewType": "catEdit.catScratch",
      "displayName": "Cat Scratch",
      "selector": [
        {
          "filenamePattern": "*.cscratch"
        }
      ],
      "priority": "default"
    }
  ]
}

customEditors 是一个数组,因此您的扩展可以贡献多个自定义编辑器。让我们分解一下自定义编辑器条目本身

  • viewType - 您的自定义编辑器的唯一标识符。

    这是 VS Code 如何将 `package.json` 中的自定义编辑器贡献与代码中的自定义编辑器实现联系起来的方式。这在所有扩展中必须是唯一的,因此不要使用通用的 `viewType`,例如 `"preview"`,请确保使用对您的扩展而言唯一的 `viewType`,例如 `"viewType": "myAmazingExtension.svgPreview"`

  • displayName - 在 VS Code UI 中识别自定义编辑器的名称。

    显示名称会显示给用户,例如在 VS Code UI 的 **View: Reopen with** 下拉菜单中。

  • selector - 指定自定义编辑器对哪些文件激活。

    selector 是一个或多个 glob 模式的数组。这些 glob 模式与文件名匹配,以确定自定义编辑器是否可用于它们。像 *.png 这样的 filenamePattern 将为所有 PNG 文件启用自定义编辑器。

    您还可以创建更具体的模式,匹配文件或目录名,例如 **/translations/*.json

  • priority -(可选)指定何时使用自定义编辑器。

    priority 控制资源打开时何时使用自定义编辑器。可能的值为

    • "default" - 尝试对所有匹配自定义编辑器 selector 的文件使用自定义编辑器。如果给定文件有多个自定义编辑器,用户将必须选择他们想要使用的自定义编辑器。
    • "option" - 默认情况下不使用自定义编辑器,但允许用户切换到它或将其配置为默认编辑器。

自定义编辑器激活

当用户打开您的某个自定义编辑器时,VS Code 会触发 `onCustomEditor:VIEW_TYPE` 激活事件。在激活期间,您的扩展必须调用 `registerCustomEditorProvider` 来注册具有预期 `viewType` 的自定义编辑器。

重要的是要注意,`onCustomEditor` 仅在 VS Code 需要创建您的自定义编辑器实例时才会被调用。如果 VS Code 仅仅向用户显示有关可用自定义编辑器的信息(例如使用 **View: Reopen with** 命令),您的扩展将不会被激活。

自定义文本编辑器

自定义文本编辑器允许您为文本文件创建自定义编辑器。这可以是任何内容,从纯非结构化文本到 CSV、JSON 或 XML。自定义文本编辑器使用 VS Code 的标准 TextDocument 作为其文档模型。

自定义编辑器扩展示例 包含一个简单的猫咪涂鸦文件(只是以 .cscratch 文件扩展名结尾的 JSON 文件)的示例自定义文本编辑器。让我们来看看实现自定义文本编辑器的一些重要部分。

自定义文本编辑器生命周期

VS Code 处理自定义文本编辑器的视图组件(webview)和模型组件(TextDocument)的生命周期。当需要创建新的自定义编辑器实例时,VS Code 会调用您的扩展,并在用户关闭其选项卡时清理编辑器实例和文档模型。

为了理解这在实践中是如何运作的,让我们来看看当用户打开自定义文本编辑器和关闭自定义文本编辑器时,从扩展的角度会发生什么。

打开自定义文本编辑器

使用 自定义编辑器扩展示例,以下是用户首次打开 .cscratch 文件时发生的情况:

  1. VS Code 触发 `onCustomEditor:catCustoms.catScratch` 激活事件。

    如果我们的扩展尚未激活,这将激活它。在激活期间,我们的扩展必须确保通过调用 `registerCustomEditorProvider` 为 `catCustoms.catScratch` 注册一个 `CustomTextEditorProvider`。

  2. 然后,VS Code 在为 catCustoms.catScratch 注册的 CustomTextEditorProvider 上调用 resolveCustomTextEditor

    此方法接受正在打开的资源的 TextDocument 和一个 WebviewPanel。扩展必须为此 Webview 面板填充初始 HTML 内容。

一旦 resolveCustomTextEditor 返回,我们的自定义编辑器就会显示给用户。Webview 内部绘制的内容完全取决于我们的扩展。

每次打开自定义编辑器时,即使您拆分自定义编辑器,也会发生相同的流程。自定义编辑器的每个实例都有自己的 `WebviewPanel`,尽管如果多个自定义文本编辑器是针对同一资源的,它们将共享相同的 `TextDocument`。请记住:将 `TextDocument` 视为资源的模型,而 Webview 面板是该模型的视图。

关闭自定义文本编辑器

当用户关闭自定义文本编辑器时,VS Code 会在 WebviewPanel 上触发 WebviewPanel.onDidDispose 事件。此时,您的扩展应该清理与该编辑器相关的任何资源(事件订阅、文件监视器等)

当给定资源的最后一个自定义编辑器关闭时,该资源的 `TextDocument` 也将被释放,前提是没有其他编辑器使用它,也没有其他扩展持有它。您可以检查 `TextDocument.isClosed` 属性以查看 `TextDocument` 是否已关闭。一旦 `TextDocument` 关闭,使用自定义编辑器打开相同资源将导致打开新的 `TextDocument`。

与 TextDocument 同步更改

由于自定义文本编辑器使用 `TextDocument` 作为其文档模型,因此它们负责在自定义编辑器中发生编辑时更新 `TextDocument`,并在 `TextDocument` 更改时更新自身。

从 Webview 到 TextDocument

自定义文本编辑器中的编辑可以采取多种不同的形式——单击按钮、更改某些文本、拖动某些项目。无论何时用户在自定义文本编辑器中编辑文件本身,扩展都必须更新 `TextDocument`。以下是猫咪涂鸦扩展实现此功能的方式

  1. 用户在 Webview 中点击 **Add scratch** 按钮。这将 发布一条消息 从 Webview 返回到扩展。

  2. 扩展接收消息。然后它更新其内部文档模型(在猫咪涂鸦示例中,这仅包括向 JSON 添加新条目)。

  3. 扩展创建了一个 `WorkspaceEdit`,将更新的 JSON 写入文档。此编辑通过 `vscode.workspace.applyEdit` 应用。

尽量将您的工作区编辑保持为更新文档所需的最少更改。还要记住,如果您正在处理 JSON 等语言,您的扩展应该尝试遵循用户现有的格式约定(空格与制表符、缩进大小等)。

TextDocument 到 Webview

当 `TextDocument` 更改时,您的扩展还需要确保其 Webview 反映文档的新状态。`TextDocument` 可以通过用户操作(例如撤消、重做或文件恢复)、通过其他扩展使用 `WorkspaceEdit`,或通过在 VS Code 默认文本编辑器中打开文件的用户进行更改。以下是猫咪涂鸦扩展实现此功能的方式

  1. 在扩展中,我们订阅 vscode.workspace.onDidChangeTextDocument 事件。此事件在 TextDocument 的每次更改时都会触发(包括我们的自定义编辑器所做的更改!)

  2. 当我们有编辑器的文档发生更改时,我们会向 Webview 发送一条消息,其中包含其新的文档状态。然后此 Webview 会自行更新以渲染更新后的文档。

重要的是要记住,自定义编辑器触发的任何文件编辑都将导致 onDidChangeTextDocument 触发。确保您的扩展不会陷入更新循环,即用户在 Webview 中进行编辑,这会触发 onDidChangeTextDocument,从而导致 Webview 更新,从而导致 Webview 在您的扩展上触发另一次更新,这又会触发 onDidChangeTextDocument,依此类推。

另请记住,如果您正在使用 JSON 或 XML 等结构化语言,文档可能并不总处于有效状态。您的扩展程序必须能够优雅地处理错误,或者向用户显示错误消息,以便他们了解问题所在以及如何解决。

最后,如果更新您的 webview 成本很高,请考虑限制对 webview 的更新。

自定义编辑器

CustomEditorProviderCustomReadonlyEditorProvider 允许您为二进制文件格式创建自定义编辑器。此 API 允许您完全控制文件如何显示给用户,如何对其进行编辑,并允许您的扩展挂接到 save 和其他文件操作。再次强调,如果您正在为基于文本的文件格式构建编辑器,强烈建议使用 CustomTextEditor,因为它们实现起来要简单得多。

自定义编辑器扩展示例 包含一个简单的示例自定义二进制编辑器,用于爪印绘制文件(只是以 .pawdraw 文件扩展名结尾的 jpeg 文件)。让我们看看构建用于二进制文件的自定义编辑器需要哪些内容。

CustomDocument

对于自定义编辑器,您的扩展负责使用 CustomDocument 接口实现自己的文档模型。这让您的扩展可以自由地在 CustomDocument 上存储它所需的任何数据,以便与您的自定义编辑器交互,但这也意味着您的扩展必须实现基本的文档操作,例如保存和备份文件数据以进行热退出。

每个打开的文件有一个 `CustomDocument`。用户可以为单个资源打开多个编辑器——例如通过拆分当前自定义编辑器——但所有这些编辑器都将由同一个 `CustomDocument` 提供支持。

自定义编辑器生命周期

支持每个文档多个编辑器

默认情况下,VS Code 仅允许每个自定义文档有一个编辑器。此限制使正确实现自定义编辑器变得更容易,因为您无需担心多个自定义编辑器实例之间的同步。

但是,如果您的扩展可以支持,我们建议在注册自定义编辑器时将 `supportsMultipleEditorsPerDocument: true` 设置为允许为同一文档打开多个编辑器实例。这将使您的自定义编辑器表现得更像 VS Code 的常规文本编辑器。

打开自定义编辑器 当用户打开一个匹配 customEditor 贡献点的文件时,VS Code 会触发 onCustomEditor 激活事件,然后调用为所提供的视图类型注册的提供程序。CustomEditorProvider 有两个角色:为自定义编辑器提供文档,然后提供编辑器本身。以下是 自定义编辑器扩展示例catCustoms.pawDraw 编辑器发生的情况的有序列表

  1. VS Code 触发 onCustomEditor:catCustoms.pawDraw 激活事件。

    如果我们的扩展尚未激活,这将激活它。我们还必须确保我们的扩展在激活期间为 catCustoms.pawDraw 编辑器注册一个 CustomReadonlyEditorProviderCustomEditorProvider

  2. VS Code 在为 catCustoms.pawDraw 编辑器注册的 CustomReadonlyEditorProviderCustomEditorProvider 上调用 openCustomDocument

    在此处,我们的扩展被赋予一个资源 URI,并且必须为该资源返回一个新的 CustomDocument。这是我们的扩展应该为该资源创建其文档内部模型的时候。这可能涉及从磁盘读取和解析初始资源状态,或者初始化我们的新 CustomDocument

    我们的扩展可以通过创建一个实现 CustomDocument 的新类来定义此模型。请记住,此初始化阶段完全取决于扩展;VS Code 不关心扩展在 CustomDocument 上存储的任何附加信息。

  3. VS Code 使用步骤 2 中的 CustomDocument 和一个新的 WebviewPanel 调用 resolveCustomEditor

    在这里,我们的扩展必须填充自定义编辑器的初始 HTML。如果需要,我们还可以保留对 WebviewPanel 的引用,以便以后可以引用它,例如在命令内部。

一旦 resolveCustomEditor 返回,我们的自定义编辑器就会显示给用户。

如果用户使用我们的自定义编辑器在另一个编辑器组中打开相同的资源——例如通过拆分第一个编辑器——扩展的工作将简化。在这种情况下,VS Code 只会使用我们打开第一个编辑器时创建的相同 CustomDocument 调用 resolveCustomEditor

关闭自定义编辑器

假设我们有两个针对相同资源的自定义编辑器实例打开。当用户关闭这些编辑器时,VS Code 会向我们的扩展发送信号,以便它可以清理与编辑器相关的任何资源。

当第一个编辑器实例关闭时,VS Code 会在关闭的编辑器中的 WebviewPanel 上触发 WebviewPanel.onDidDispose 事件。此时,我们的扩展必须清理与该特定编辑器实例相关的任何资源。

当第二个编辑器关闭时,VS Code 会再次触发 `WebviewPanel.onDidDispose`。但是,现在我们也关闭了与 `CustomDocument` 相关联的所有编辑器。当 `CustomDocument` 没有更多编辑器时,VS Code 会在其上调用 `CustomDocument.dispose`。我们的扩展对 `dispose` 的实现必须清理与文档相关联的任何资源。

如果用户随后使用我们的自定义编辑器重新打开相同的资源,我们将通过新的 CustomDocument 再次经历整个 openCustomDocumentresolveCustomEditor 流程。

只读自定义编辑器

以下许多部分仅适用于支持编辑的自定义编辑器,尽管这听起来可能自相矛盾,但许多自定义编辑器根本不需要编辑功能。例如图像预览。或内存转储的可视化渲染。两者都可以使用自定义编辑器实现,但都不需要可编辑。这就是 CustomReadonlyEditorProvider 的用武之地。

CustomReadonlyEditorProvider 允许您创建不支持编辑的自定义编辑器。它们仍然可以交互,但不支持撤消和保存等操作。与完全可编辑的自定义编辑器相比,实现只读自定义编辑器也简单得多。

可编辑自定义编辑器基础知识

可编辑自定义编辑器允许您挂接到标准 VS Code 操作,例如撤消和重做、保存和热退出。这使得可编辑自定义编辑器非常强大,但也意味着正确实现一个比实现可编辑自定义文本编辑器或只读自定义编辑器复杂得多。

可编辑自定义编辑器由 CustomEditorProvider 实现。此接口扩展了 CustomReadonlyEditorProvider,因此您必须实现基本操作,例如 openCustomDocumentresolveCustomEditor,以及一组特定于编辑的操作。让我们看看 CustomEditorProvider 中特定于编辑的部分。

编辑

对可编辑自定义文档的更改通过编辑来表达。编辑可以是任何内容,从文本更改到图像旋转,再到重新排序列表。VS Code 将编辑的具体操作完全留给您的扩展,但 VS Code 需要知道何时发生编辑。编辑是 VS Code 标记文档为脏的方式,这反过来又启用了自动保存和备份。

无论用户在您的自定义编辑器的任何 Webview 中进行任何编辑,您的扩展都必须从其 CustomEditorProvider 中触发 onDidChangeCustomDocument 事件。onDidChangeCustomDocument 事件可以根据您的自定义编辑器实现触发两种事件类型:CustomDocumentContentChangeEventCustomDocumentEditEvent

CustomDocumentContentChangeEvent

CustomDocumentContentChangeEvent 是一个最基本的编辑。它的唯一功能是告诉 VS Code 文档已被编辑。

当扩展从 onDidChangeCustomDocument 触发 CustomDocumentContentChangeEvent 时,VS Code 会将关联的文档标记为脏。此时,文档变为非脏的唯一方法是用户保存或还原它。使用 CustomDocumentContentChangeEvent 的自定义编辑器不支持撤消/重做。

CustomDocumentEditEvent

CustomDocumentEditEvent 是一种更复杂的编辑,允许撤消/重做。您应该始终尝试使用 CustomDocumentEditEvent 实现您的自定义编辑器,并且只有在无法实现撤消/重做时才回退到使用 CustomDocumentContentChangeEvent

CustomDocumentEditEvent 具有以下字段

  • document — 编辑所针对的 CustomDocument
  • label — 可选文本,描述进行了何种类型的编辑(例如:“裁剪”、“插入”等)
  • undo — 当需要撤消编辑时,由 VS Code 调用的函数。
  • redo — 当需要重做编辑时,由 VS Code 调用的函数。

当扩展从 onDidChangeCustomDocument 触发 CustomDocumentEditEvent 时,VS Code 会将关联的文档标记为脏。为了使文档不再脏,用户可以保存或还原文档,或者撤消/重做回文档的上次保存状态。

编辑器上的 undoredo 方法由 VS Code 调用,以撤消或重新应用特定编辑。VS Code 维护一个内部编辑堆栈,因此如果您的扩展用三个编辑触发 onDidChangeCustomDocument,我们称之为 abc

onDidChangeCustomDocument(a);
onDidChangeCustomDocument(b);
onDidChangeCustomDocument(c);

以下用户操作序列导致这些调用

undo — c.undo()
undo — b.undo()
redo — b.redo()
redo — c.redo()
redo — no op, no more edits

要实现撤消/重做,您的扩展必须更新其关联的自定义文档的内部状态,并更新文档的所有关联 Webview,以使它们反映文档的新状态。请记住,单个资源可能存在多个 Webview。这些 Webview 必须始终显示相同的文档数据。例如,图像编辑器的多个实例必须始终显示相同的像素数据,但可以允许每个编辑器实例具有自己的缩放级别和 UI 状态。

保存

当用户保存自定义编辑器时,您的扩展负责将其当前状态的保存资源写入磁盘。您的自定义编辑器如何做到这一点主要取决于您的扩展的 CustomDocument 类型以及您的扩展如何内部跟踪编辑。

保存的第一步是获取要写入磁盘的数据流。常见的方法包括

  • 跟踪资源状态,以便可以快速序列化。

    例如,一个基本的图像编辑器可能会维护一个像素数据缓冲区。

  • 重播自上次保存以来的编辑,以生成新文件。

    例如,一个更高效的图像编辑器可能会跟踪自上次保存以来的编辑,例如 croprotatescale。保存时,它会将这些编辑应用于文件的上次保存状态以生成新文件。

  • 向自定义编辑器的 WebviewPanel 请求要保存的文件数据。

    但是请记住,即使自定义编辑器不可见,也可以保存它们。因此,建议您的扩展程序的 save 实现不依赖于 WebviewPanel。如果不可能,您可以使用 WebviewPanelOptions.retainContextWhenHidden 设置,以便即使 Webview 被隐藏,它也能保持活动状态。retainContextWhenHidden 确实会产生大量的内存开销,因此请谨慎使用它。

获取资源数据后,您通常应该使用 工作区 FS API 将其写入磁盘。FS API 接受 UInt8Array 类型的数据,可以写入二进制文件和文本文件。对于二进制文件数据,只需将二进制数据放入 UInt8Array。对于文本文件数据,使用 Buffer 将字符串转换为 UInt8Array

const writeData = Buffer.from('my text data', 'utf8');
vscode.workspace.fs.writeFile(fileUri, writeData);

后续步骤

如果您想了解更多关于 VS Code 扩展性的信息,请尝试以下主题