如何将 Plate 编辑器与 react-hook-form 集成。

虽然 Plate 通常用作非受控输入,但在某些场景下,您可能希望将编辑器集成到表单库中,比如 react-hook-formshadcn/uiForm 组件。本指南将介绍最佳实践和常见陷阱。

何时将 Plate 与表单集成

  • 表单提交:您希望在用户提交表单时,将编辑器内容与其他字段(如 <input><select>)一起包含。
  • 验证:您希望同时验证编辑器内容(例如,检查是否为空)和其他表单字段。
  • 表单数据管理:您希望将编辑器内容存储在与表单字段相同的存储中(如 react-hook-form 的状态)。

但是,请注意关于完全控制编辑器值的警告。Plate 强烈推荐使用非受控模型。如果您过于频繁地替换编辑器的内部状态,可能会破坏选择历史记录或导致性能问题。推荐的模式是将编辑器视为非受控的,但在特定事件上仍然同步表单数据。

方法 1:在 onChange 时同步

这是最直接的方法:每次编辑器更改时,更新表单字段的值。对于小型文档或不频繁的更改,这通常是可以接受的。

React Hook Form 示例

import { useForm } from 'react-hook-form';
import type { Value } from 'platejs';
import { Plate, PlateContent, usePlateEditor } from 'platejs/react';
 
type FormData = {
  content: Value;
};
 
export function RHFEditorForm() {
  const initialValue = [
    { type: 'p', children: [{ text: '来自 react-hook-form 的问候!' }] },
  ]
 
  // 设置 react-hook-form
  const { register, handleSubmit, setValue } = useForm<FormData>({
    defaultValues: {
      content: initialValue,
    },
  });
 
  // 创建/配置 Plate 编辑器
  const editor = usePlateEditor({ value: initialValue });
 
  // 为 react-hook-form 注册字段
  register('content', { /* 验证规则... */ });
 
  const onSubmit = (data: FormData) => {
    // data.content 将包含最终的编辑器内容
    console.info('已提交:', data.content);
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Plate
        editor={editor}
        onChange={({ value }) => {
          // 将编辑器更改同步到表单
          setValue('content', value);
        }}
      >
        <PlateContent placeholder="在此输入..." />
      </Plate>
 
      <button type="submit">提交</button>
    </form>
  );
}

注意事项

  1. defaultValues.content:您的初始编辑器内容。
  2. register('content'):向 RHF 表明该字段被跟踪。
  3. onChange({ value }):每次调用 setValue('content', value)

如果您处理大型文档或快速输入,请考虑使用防抖或切换到 onBlur 方法来减少表单更新。

shadcn/ui 表单示例

shadcn/ui 提供了一个与 react-hook-form 集成的 <Form>。我们将使用 <FormField> 来处理字段逻辑:

import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { useForm } from 'react-hook-form';
import { Plate, PlateContent, usePlateEditor } from 'platejs/react';
 
type FormValues = {
  content: any;
};
 
export function EditorForm() {
  // 1. 创建表单
  const form = useForm<FormValues>({
    defaultValues: {
      content: [
        { type: 'p', children: [{ text: '来自 shadcn/ui Form 的问候!' }] },
      ],
    },
  });
 
  // 2. 创建 Plate 编辑器
  const editor = usePlateEditor();
 
  const onSubmit = (data: FormValues) => {
    console.info('已提交数据:', data.content);
  };
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="content"
          render={({ field }) => (
            <FormItem>
              <FormLabel>内容</FormLabel>
              <FormControl>
                <Plate
                  editor={editor}
                  onChange={({ value }) => {
                    // 同步到表单
                    field.onChange(value);
                  }}
                >
                  <PlateContent placeholder="输入..." />
                </Plate>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <button type="submit">提交</button>
      </form>
    </Form>
  );
}

这种方法使您的编辑器内容的行为与 shadcn/ui 表单中的其他字段一样。

方法 2:在失焦时同步(或其他触发方式)

您可能只需要在用户以下情况时获取最终值:

  • 离开编辑器(onBlur
  • 点击"保存"按钮
  • 达到某些表单提交逻辑
<Plate editor={editor}>
  <PlateContent
    onBlur={() => {
      // 仅在失焦时同步
      setValue('content', editor.children);
    }}
  />
</Plate>

这减少了开销,但您的表单状态在用户输入时不会反映部分更新。

方法 3:受控替换(高级)

如果您希望表单成为单一数据源(完全受控):

editor.tf.setValue(formStateValue);

但这有已知的缺点:

  • 可能会破坏光标位置和撤销/重做功能
  • 对于大型文档可能导致频繁的完全重新渲染

建议:如果可能,坚持使用部分非受控模型。

示例:保存和重置

这是一个更完整的表单示例,展示了如何保存和重置编辑器/表单:

import { useForm } from 'react-hook-form';
import { Plate, PlateContent, usePlateEditor } from 'platejs/react';
 
function MyForm() {
  const form = useForm({
    defaultValues: {
      content: [
        { type: 'p', children: [{ text: '初始内容...' }] },
      ],
    },
  });
 
  const editor = usePlateEditor();
 
  const onSubmit = (data) => {
    alert(JSON.stringify(data, null, 2));
  };
 
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <Plate
        editor={editor}
        onChange={({ value }) => form.setValue('content', value)}
      >
        <PlateContent />
      </Plate>
 
      <button type="submit">保存</button>
 
      <button
        type="button"
        onClick={() => {
          // 重置编辑器
          editor.tf.reset();
          // 重置表单
          form.reset();
        }}
      >
        重置
      </button>
    </form>
  );
}
  • onChange -> 更新表单状态
  • 重置 -> 同时调用 editor.tf.reset()form.reset() 以保持一致性

从 shadcn Textarea 迁移到 Plate

如果您有一个标准的 shadcn/ui 文档中的 TextareaForm,并想用 Plate 编辑器替换 <Textarea>,可以按照以下步骤操作:

// 1. 原始代码 (TextareaForm)
<FormField
  control={form.control}
  name="bio"
  render={({ field }) => (
    <FormItem>
      <FormLabel>个人简介</FormLabel>
      <FormControl>
        <Textarea
          placeholder="告诉我们一些关于你自己的信息"
          className="resize-none"
          {...field}
        />
      </FormControl>
      <FormDescription>
        您可以 <span>@提及</span> 其他用户和组织。
      </FormDescription>
      <FormMessage />
    </FormItem>
  )}
/>

创建一个新的 EditorField 组件:

// EditorField.tsx
"use client";
 
import * as React from "react";
import type { Value } from "platejs";
import { Plate, PlateContent, usePlateEditor } from "platejs/react";
 
/**
 * 一个可重用的编辑器组件,工作方式类似于标准的 <Textarea>,
 * 接受 `value`、`onChange` 和可选的 placeholder。
 *
 * 用法:
 *
 * <FormField
 *   control={form.control}
 *   name="bio"
 *   render={({ field }) => (
 *     <FormItem>
 *       <FormLabel>个人简介</FormLabel>
 *       <FormControl>
 *         <EditorField
 *           {...field}
 *           placeholder="告诉我们一些关于你自己的信息"
 *         />
 *       </FormControl>
 *       <FormDescription>一些有帮助的描述...</FormDescription>
 *       <FormMessage />
 *     </FormItem>
 *   )}
 * />
 */
export interface EditorFieldProps
  extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * 当前的 Plate 值。应该是一个 Plate 节点数组。
   */
  value?: Value;
 
  /**
   * 当编辑器值改变时调用。
   */
  onChange?: (value: Value) => void;
 
  /**
   * 编辑器为空时显示的占位符文本。
   */
  placeholder?: string;
}
 
export function EditorField({
  value,
  onChange,
  placeholder = "在此输入...",
  ...props
}: EditorFieldProps) {
  // 使用提供的初始 `value` 创建编辑器实例。
  const editor = usePlateEditor({
    value: value ?? [
      { type: "p", children: [{ text: "" }] }, // 默认空段落
    ],
  });
 
  return (
    <Plate
      editor={editor}
      onChange={({ value }) => {
        // 通过 onChange prop 将更改同步回调用者
        onChange?.(value);
      }}
      {...props}
    >
      <PlateContent placeholder={placeholder} />
    </Plate>
  );
}
  1. <EditorField> 替换 <Textarea> 块:
"use client";
 
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
 
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { EditorField } from "./EditorField"; // 导入上面的组件
 
// 1. 使用 zod 定义验证模式
const FormSchema = z.object({
  bio: z
    .string()
    .min(10, { message: "个人简介至少需要 10 个字符。" })
    .max(160, { message: "个人简介不能超过 160 个字符。" }),
});
 
// 2. 构建主表单组件
export function EditorForm() {
  // 3. 设置表单
  const form = useForm<z.infer<typeof FormSchema>>({
    resolver: zodResolver(FormSchema),
    defaultValues: {
      // 这里 "bio" 只是一个字符串,但如果您将其解析为 JSON,
      // 我们的编辑器会将其解释为初始内容
      bio: "",
    },
  });
 
  // 4. 提交处理函数
  function onSubmit(data: z.infer<typeof FormSchema>) {
    alert("已提交: " + JSON.stringify(data, null, 2));
  }
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
        <FormField
          control={form.control}
          name="bio"
          render={({ field }) => (
            <FormItem>
              <FormLabel>个人简介</FormLabel>
              <FormControl>
                <EditorField
                  {...field}
                  placeholder="告诉我们一些关于你自己的信息..."
                />
              </FormControl>
              <FormDescription>
                您可以 <span>@提及</span> 其他用户和组织。
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <button type="submit" className="py-2 px-4 bg-primary text-white">
          提交
        </button>
      </form>
    </Form>
  );
}
  • 任何现有的表单验证或错误消息保持不变。
  • 对于默认值,确保 form.defaultValues.bio 是一个有效的 Plate 值(节点数组)而不是字符串。
  • 对于受控值,适度使用 editor.tf.setValue(formStateValue)

最佳实践

  1. 使用非受控编辑器:让 Plate 管理自己的状态,仅在必要时更新表单。
  2. 最小化替换:避免过于频繁地调用 editor.tf.setValue,这可能会破坏选择、历史记录或降低性能。
  3. 在适当的时机验证:决定是需要即时验证(输入时)还是在失焦/提交时验证。
  4. 同时重置:如果重置表单,调用 editor.tf.reset() 以保持同步。