在 WordPress 块编辑器中添加自定义欢迎指南

Avatar of Leonardo Losoviz
Leonardo Losoviz 发布

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

我正在开发一个 WordPress 插件,在使用它时存在一定的学习曲线。我想为用户提供一个入门指南,说明如何使用该插件,但我希望避免将用户引导到插件网站上的文档,因为这会打断了他们的体验。

理想情况下,用户在安装插件后可以立即开始使用,并在积极使用过程中获得有用的提示。WordPress 中没有原生功能可以实现这一点,但我们可以自己创建,因为 WordPress 非常灵活。

所以,这就是我们的想法。我们将文档直接嵌入到插件中,并在块编辑器中使其易于访问。这样,用户可以立即使用插件,同时可以直接在工作区域获得常见问题的答案。

我的插件通过多个自定义文章类型 (CPT) 进行操作。我们将要构建的内容本质上是一个弹出模态框,用户在访问这些 CPT 时会看到它。

WordPress 块编辑器是使用 React 构建的,它利用可以针对不同情况进行自定义和重复使用的组件。我们正在构建的内容也是如此——我们将其称为 <Guide> 组件——它像一个模态框,但由用户可以分页浏览的多个页面组成。

WordPress 本身有一个 <Guide> 组件,在第一次打开块编辑器时会显示欢迎指南。

Screenshot showing a modal on top of the WordPress block editor welcoming users to the editor for the first time.
当用户第一次加载编辑器时,WordPress 会显示一个包含块编辑器使用说明的模态框。

该指南是一个包含内容的容器,内容被分成单独的页面。换句话说,这正是我们想要的。这意味着我们不必在这个项目中重新发明轮子;我们可以重用这个概念。

让我们这样做。

我们想要实现的目标

在我们开始解决方法之前,让我们先讨论一下最终目标。

该设计满足了插件的需求,该插件是 WordPress 的 GraphQL 服务器。该插件提供了各种通过自定义块编辑的 CPT,而自定义块又通过 模板定义。总共有两个块:一个名为“GraphiQL 客户端”用于输入 GraphQL 查询,另一个名为“持久化查询选项”用于自定义执行行为。

由于创建 GraphQL 查询并非易事,因此我决定将指南组件添加到该 CPT 的编辑器屏幕中。它在文档设置中作为一个名为“欢迎指南”的面板提供。

Screenshot showing the WordPress editor with the document settings panel open in the right column. a welcome guide tab is highlighted in the settings.

打开该面板,用户会看到一个链接。该链接将触发模态框。

Close-up screenshot of the welcome guide tab opened, revealing a link that says "Open Guide: Creating Persisted Queries."

对于模态框本身,我决定在第一页上显示一个关于如何使用 CPT 的教程视频,然后在后续页面上详细描述 CPT 中可用的所有选项。

Screenshot showing the custom modal open in the block editor and containing an embedded video on how to use the plugin.

我相信这种布局是向用户展示文档的有效方法。它不会妨碍用户操作,但仍然方便地靠近操作区域。当然,我们可以使用不同的设计,甚至可以使用不同的组件将模态框触发器放置在其他位置,而不是重用 <Guide>,但这已经足够好了。

规划实现

实现包括以下步骤

  1. 搭建一个新的脚本以注册自定义侧边栏面板
  2. 仅在我们的自定义文章类型的编辑器中显示自定义侧边栏面板
  3. 创建指南
  4. 向指南添加内容

让我们开始吧!

步骤 1:搭建脚本

从 WordPress 5.4 开始,我们可以使用名为 <PluginDocumentSettingPanel> 的组件在编辑器的文档设置中添加一个 面板,如下所示

const { registerPlugin } = wp.plugins;
const { PluginDocumentSettingPanel } = wp.editPost;
 
const PluginDocumentSettingPanelDemo = () => (
  <PluginDocumentSettingPanel
    name="custom-panel"
    title="Custom Panel"
    className="custom-panel"
  >
    Custom Panel Contents
  </PluginDocumentSettingPanel>
);
registerPlugin( 'plugin-document-setting-panel-demo', {
  render: PluginDocumentSettingPanelDemo,
  icon: 'palmtree',
} );

如果您熟悉块编辑器并已经知道如何执行此代码,则可以跳过此步骤。我使用块编辑器编程的时间不到三个月,并且使用 React/npm/webpack 对我来说是一个全新的世界——这个插件是我使用它们开发的第一个项目!我发现 Gutenberg 仓库 中的文档并不总是适合像我这样的初学者,有时文档甚至缺失,因此我不得不深入研究源代码以找到答案。

当组件的文档指示使用上面的代码片段时,我不知道接下来该怎么做,因为 <PluginDocumentSettingPanel> 不是一个块,我无法搭建一个新的块或在那里添加代码。此外,我们正在使用 JSX,这意味着我们需要一个 JavaScript 构建步骤 来编译代码。

但是,我找到了 等效的 ES5 代码

var el = wp.element.createElement;
var __ = wp.i18n.__;
var registerPlugin = wp.plugins.registerPlugin;
var PluginDocumentSettingPanel = wp.editPost.PluginDocumentSettingPanel;


function MyDocumentSettingPlugin() {
  return el(
    PluginDocumentSettingPanel,
    {
      className: 'my-document-setting-plugin',
      title: 'My Panel',
    },
    __( 'My Document Setting Panel' )
  );
}


registerPlugin( 'my-document-setting-plugin', {
  render: MyDocumentSettingPlugin
} );

ES5 代码不需要编译,因此我们可以像在 WordPress 中加载任何其他脚本一样加载它。但我不想使用它。我想要完整的、现代的 ESNext 和 JSX 体验。

所以我的想法是这样的:我不能使用 块搭建工具,因为它不是一个块,而且我不知道如何编译脚本(我当然不会自己设置 webpack)。这意味着我被卡住了。

但是等等!块和普通脚本之间的唯一区别在于它们在 WordPress 中的注册方式。块是这样注册的

wp_register_script($blockScriptName, $blockScriptURL, $dependencies, $version);
register_block_type('my-namespace/my-block', [
  'editor_script' => $blockScriptName,
]);

而普通脚本是这样注册的

wp_register_script($scriptName, $scriptURL, $dependencies, $version);
wp_enqueue_script($scriptName);

我们可以使用任何块搭建工具来修改内容,然后注册一个普通脚本而不是块,这使我们可以访问 webpack 配置来编译 ESNext 代码。这些可用的工具是

我选择使用 @wordpress/create-block 包,因为它由开发 Gutenberg 的团队维护。

要搭建块,我们在命令行中执行此操作

npm init @wordpress/block

完成所有信息提示后——包括块的名称、标题和描述——该工具将生成一个 单块插件,其中包含一个入口 PHP 文件,其中包含类似以下的代码

/**
 * Registers all block assets so that they can be enqueued through the block editor
 * in the corresponding context.
 *
 * @see https://developer.wordpress.org/block-editor/tutorials/block-tutorial/applying-styles-with-stylesheets/
 */
function my_namespace_my_block_block_init() {
  $dir = dirname( __FILE__ );


  $script_asset_path = "$dir/build/index.asset.php";
  if ( ! file_exists( $script_asset_path ) ) {
    throw new Error(
      'You need to run `npm start` or `npm run build` for the "my-namespace/my-block" block first.'
    );
  }
  $index_js     = 'build/index.js';
  $script_asset = require( $script_asset_path );
  wp_register_script(
    'my-namespace-my-block-block-editor',
    plugins_url( $index_js, __FILE__ ),
    $script_asset['dependencies'],
    $script_asset['version']
  );


  $editor_css = 'editor.css';
  wp_register_style(
    'my-namespace-my-block-block-editor',
    plugins_url( $editor_css, __FILE__ ),
    array(),
    filemtime( "$dir/$editor_css" )
  );


  $style_css = 'style.css';
  wp_register_style(
    'my-namespace-my-block-block',
    plugins_url( $style_css, __FILE__ ),
    array(),
    filemtime( "$dir/$style_css" )
  );


  register_block_type( 'my-namespace/my-block', array(
    'editor_script' => 'my-namespace-my-block-block-editor',
    'editor_style'  => 'my-namespace-my-block-block-editor',
    'style'         => 'my-namespace-my-block-block',
  ) );
}
add_action( 'init', 'my_namespace_my_block_block_init' );

我们可以将此代码复制到插件中,并进行适当修改,将块转换为普通脚本。(请注意,我还在此过程中删除了 CSS 文件,但如果需要,可以保留它们。)

function my_script_init() {
  $dir = dirname( __FILE__ );


  $script_asset_path = "$dir/build/index.asset.php";
  if ( ! file_exists( $script_asset_path ) ) {
    throw new Error(
      'You need to run `npm start` or `npm run build` for the "my-script" script first.'
    );
  }
  $index_js     = 'build/index.js';
  $script_asset = require( $script_asset_path );
  wp_register_script(
    'my-script',
    plugins_url( $index_js, __FILE__ ),
    $script_asset['dependencies'],
    $script_asset['version']
  );
  wp_enqueue_script(
    'my-script'
  );
}
add_action( 'init', 'my_script_init' );

让我们复制 package.json 文件

{
  "name": "my-block",
  "version": "0.1.0",
  "description": "This is my block",
  "author": "The WordPress Contributors",
  "license": "GPL-2.0-or-later",
  "main": "build/index.js",
  "scripts": {
    "build": "wp-scripts build",
    "format:js": "wp-scripts format-js",
    "lint:css": "wp-scripts lint-style",
    "lint:js": "wp-scripts lint-js",
    "start": "wp-scripts start",
    "packages-update": "wp-scripts packages-update"
  },
  "devDependencies": {
    "@wordpress/scripts": "^9.1.0"
  }
}

现在,我们可以用上面 ESNext 代码替换文件 src/index.js 的内容来注册 <PluginDocumentSettingPanel> 组件。运行 npm start(或用于生产环境的 npm run build)后,代码将被编译到 build/index.js 中。

还有一个问题需要解决:<PluginDocumentSettingPanel> 组件不是静态导入的,而是从 wp.editPost 中获取的,并且由于 wp 是 WordPress 在运行时加载的全局变量,因此此依赖项不存在于 index.asset.php 中(在构建过程中自动生成)。在注册脚本时,我们必须手动向 wp-edit-post 脚本添加依赖项,以确保它在我们脚本之前加载

$dependencies = array_merge(
  $script_asset['dependencies'],
  [
    'wp-edit-post',
  ]
);
wp_register_script(
  'my-script',
  plugins_url( $index_js, __FILE__ ),
  $dependencies,
  $script_asset['version']
);

现在脚本设置已准备就绪!

可以使用 Gutenberg 的持续开发周期更新插件。运行 npm run packages-update 以更新 npm 依赖项(以及由此产生的 webpack 配置,该配置在包 "@wordpress/scripts" 中定义)到其最新支持的版本。

此时,您可能想知道我如何知道在我们的脚本之前向 "wp-edit-post" 脚本添加依赖项。好吧,我不得不深入研究 Gutenberg 的源代码。<PluginDocumentSettingPanel> 的文档有些不足,这完美地说明了 Gutenberg 的文档在某些地方存在不足。

在深入研究代码和浏览文档时,我发现了一些有启发性的内容。例如,有两种方法可以编写我们的脚本:使用 ES5 或 ESNext 语法。ES5 不需要构建过程,并且引用运行时环境中的代码实例,很可能是通过全局 wp 变量。例如,创建图标的代码如下所示

var moreIcon = wp.element.createElement( 'svg' );

ESNext 依靠 webpack 来解析所有依赖项,这使我们能够导入静态组件。例如,创建图标的代码将是

import { more } from '@wordpress/icons';

这几乎适用于所有地方。但是,<PluginDocumentSettingPanel> 组件并非如此,它 引用了 ESNext 的运行时环境

const { PluginDocumentSettingPanel } = wp.editPost;

这就是为什么我们必须向“wp-edit-post”脚本添加依赖项的原因。wp.editPost 变量是在那里定义的。

如果可以直接导入<PluginDocumentSettingPanel>,那么对“wp-edit-post”的依赖将由块编辑器通过Dependency Extraction Webpack Plugin自动处理。此插件通过创建一个包含运行时环境脚本所有依赖项的index.asset.php文件,构建了从静态到运行时的桥梁,这些依赖项是通过将包名称中的"@wordpress/"替换为"wp-"获得的。因此,"@wordpress/edit-post"包变成了"wp-edit-post"运行时脚本。这就是我如何确定要添加哪个脚本作为依赖项。

步骤 2:在所有其他自定义文章类型上将自定义侧边栏面板列入黑名单

该面板将显示特定自定义文章类型的文档,因此它必须仅注册到该自定义文章类型。这意味着我们需要将其列入黑名单,使其不会出现在任何其他文章类型上。

Ryan Welcher(创建<PluginDocumentSettingPanel>组件的人)在注册面板时描述了此过程

const { registerPlugin } = wp.plugins;
const { PluginDocumentSettingPanel } = wp.editPost
const { withSelect } = wp.data;


const MyCustomSideBarPanel = ( { postType } ) => {


  if ( 'post-type-name' !== postType ) {
    return null;
  }


  return(
    <PluginDocumentSettingPanel
      name="my-custom-panel"
      title="My Custom Panel"
    >
      Hello, World!
    </PluginDocumentSettingPanel>
  );
}


const CustomSideBarPanelwithSelect = withSelect( select => {
  return {
    postType: select( 'core/editor' ).getCurrentPostType(),
  };
} )( MyCustomSideBarPanel);




registerPlugin( 'my-custom-panel', { render: CustomSideBarPanelwithSelect } );

他还建议了一种替代方案,使用useSelect而不是withSelect

也就是说,我并不完全相信这种解决方案,因为即使不需要,JavaScript 文件也必须加载,这会迫使网站承受性能损失。难道不注册 JavaScript 文件比运行 JavaScript 仅仅是为了禁用 JavaScript 更有意义吗?

我创建了一个 PHP 解决方案。我承认它感觉有点像黑客行为,但它运行良好。首先,我们找出哪个文章类型与正在创建或编辑的对象相关联。

function get_editing_post_type(): ?string
{
  if (!is_admin()) {
    return null;
  }


  global $pagenow;
  $typenow = '';
  if ( 'post-new.php' === $pagenow ) {
    if ( isset( $_REQUEST['post_type'] ) && post_type_exists( $_REQUEST['post_type'] ) ) {
      $typenow = $_REQUEST['post_type'];
    };
  } elseif ( 'post.php' === $pagenow ) {
    if ( isset( $_GET['post'] ) && isset( $_POST['post_ID'] ) && (int) $_GET['post'] !== (int) $_POST['post_ID'] ) {
      // Do nothing
    } elseif ( isset( $_GET['post'] ) ) {
      $post_id = (int) $_GET['post'];
    } elseif ( isset( $_POST['post_ID'] ) ) {
      $post_id = (int) $_POST['post_ID'];
    }
    if ( $post_id ) {
      $post = get_post( $post_id );
      $typenow = $post->post_type;
    }
  }
  return $typenow;
}

然后,我们仅在它与我们的自定义文章类型匹配时注册脚本。

add_action('init', 'maybe_register_script');
function maybe_register_script()
{
  // Check if this is the intended custom post type
  if (get_editing_post_type() != 'my-custom-post-type') {
    return;
  }


  // Only then register the block
  wp_register_script(...);
  wp_enqueue_script(...);
}

查看这篇文章以更深入地了解其工作原理。

步骤 3:创建自定义指南

我根据 WordPress 的<Guide>组件设计了插件指南的功能。一开始我没有意识到我会这样做,所以这就是我如何弄清楚的。

  1. 搜索源代码以查看它是如何在其中完成的。
  2. Gutenberg 的 Storybook中浏览所有可用组件的目录。

首先,我从块编辑器模态复制内容并进行了基本的搜索。结果将我引导至此文件。从那里我发现该组件称为<Guide>,可以简单地复制粘贴其代码到我的插件中作为我自己的指南的基础。

然后我查找了组件的文档。我浏览了@wordpress/components包(正如你可能猜到的,组件是在这里实现的)并找到了组件的 README 文件。这给了我实现我自己的自定义指南组件所需的所有信息。

我还浏览了Gutenberg 的 Storybook中所有可用组件的目录(实际上显示了这些组件可以在 WordPress 上下文之外使用)。点击所有组件,我最终发现了<Guide>。Storybook 为几个示例(或故事)提供了源代码。这是一个方便的资源,可以帮助理解如何通过 props 自定义组件。

此时,我知道<Guide>将成为我组件的坚实基础。不过,缺少一个元素:如何通过点击触发指南。我不得不绞尽脑汁想出这个!

这是一个带有监听器的按钮,点击时会打开模态。

import { useState } from '@wordpress/element';
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import MyGuide from './guide';


const MyGuideWithButton = ( props ) => {
  const [ isOpen, setOpen ] = useState( false );
  return (
    <>
      <Button onClick={ () => setOpen( true ) }>
        { __('Open Guide: “Creating Persisted Queries”') }
      </Button>
      { isOpen && (
        <MyGuide 
          { ...props }
          onFinish={ () => setOpen( false ) }
        />
      ) }
    </>
  );
};
export default MyGuideWithButton;

即使块编辑器试图隐藏它,我们仍在 React 中操作。到目前为止,我们一直在处理 JSX 和组件。但现在我们需要useState hook,它是 React 特有的。

我想说,如果你想掌握 WordPress 块编辑器,就需要对 React 有很好的理解。这是无法避免的。

步骤 4:向指南添加内容

我们快到了!让我们创建<Guide>组件,每个内容页面都包含一个<GuidePage>组件。

内容可以使用 HTML,包含其他组件等等。在本例中,我只是使用 HTML 为我的自定义文章类型添加了三个<GuidePage>实例。第一页包含视频教程,接下来的两页包含详细说明。

import { Guide, GuidePage } from '@wordpress/components';
import { __ } from '@wordpress/i18n';


const MyGuide = ( props ) => {
  return (
    <Guide { ...props } >
      <GuidePage>
        <video width="640" height="400" controls>
          <source src="https://d1c2lqfn9an7pb.cloudfront.net/presentations/graphql-api/videos/graphql-api-creating-persisted-query.mov" type="video/mp4" />
          { __('Your browser does not support the video tag.') }
        </video>
        // etc.
      </GuidePage>
      <GuidePage>
        // ...
      </GuidePage>
      <GuidePage>
        // ...
      </GuidePage>
    </Guide>
  )
}
export default MyGuide;
imaged gif showing the mouse cursor clicking on the Open Guide link in the block editor's document settings, which opens the custom welcome guide containing a video with links to other pages in the modal.
看,我们现在有自己的指南了!

不错!不过,也有一些问题。

  • 我无法将视频嵌入到<Guide>中,因为点击播放按钮会关闭指南。我假设这是因为<iframe>超出了指南的边界。我最终将视频文件上传到 S3 并使用<video>提供服务。
  • 指南中的页面过渡不够流畅。块编辑器的模态看起来不错,因为所有页面都具有相似的高度,但此处的过渡非常突然。
  • 按钮上的悬停效果可以改进。希望 Gutenberg 团队需要为他们自己的目的修复这个问题,因为我的 CSS 不在那里。并不是我的技能不好;它们根本不存在。

但我可以忍受这些问题。在功能方面,我已经实现了指南需要执行的操作。

额外内容:独立打开文档

对于我们的<Guide>,我们直接使用 HTML 创建了每个<GuidePage>组件的内容。但是,如果此 HTML 代码改为通过一个独立的组件添加,则可以重复用于其他用户交互。

例如,组件<CacheControlDescription>显示有关 HTTP 缓存的描述。

const CacheControlDescription = () => {
  return (
    <p>The Cache-Control header will contain the minimum max-age value from all fields/directives involved in the request, or "no-store" if the max-age is 0</p>
  )
}
export default CacheControlDescription;

此组件可以像我们之前那样添加到<GuidePage>中,也可以添加到<Modal>组件中。

import { useState } from '@wordpress/element';
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import CacheControlDescription from './cache-control-desc';


const CacheControlModalWithButton = ( props ) => {
  const [ isOpen, setOpen ] = useState( false );
  return (
    <>
      <Button 
        icon="editor-help"
        onClick={ () => setOpen( true ) }
      />
      { isOpen && (
        <Modal 
          { ...props }
          onRequestClose={ () => setOpen( false ) }
        >
          <CacheControlDescription />
        </Modal>
      ) }
    </>
  );
};
export default CacheControlModalWithButton;

为了提供良好的用户体验,我们可以在用户与块交互时才提供显示文档。为此,我们根据isSelected的值显示或隐藏按钮。

import { __ } from '@wordpress/i18n';
import CacheControlModalWithButton from './modal-with-btn';


const CacheControlHeader = ( props ) => {
  const { isSelected } = props;
  return (
    <>
      { __('Cache-Control max-age') }
      { isSelected && (
        <CacheControlModalWithButton />
      ) }
    </>
  );
}
export default CacheControlHeader;

最后,<CacheControlHeader>组件被添加到相应的控件中。

Animated gif showing the option to view a guide displaying when a block is selected in the editor.

太棒啦 🎉

WordPress 块编辑器是一款相当棒的软件!我能够用它完成一些如果没有它就无法完成的事情。为用户提供文档可能不是最闪亮的示例或用例,但它是一个非常实用的用例,并且与许多其他插件相关。想在你的插件中使用它吗?尽管来吧!

查看此 Cloudways 教程以进一步了解如何设置自定义页面模板。