使用 SlateJS 实现自定义占位符+底纹词效果的探索与实践

413 阅读7分钟

背景介绍

在现代文本编辑器中,底纹词(也称为文本占位符)是一种常见的交互设计模式,用于引导用户输入特定内容。例如,在一个模板化编辑器中,底纹词可以指示用户在哪里输入标题、正文或其他特定内容。与普通占位符不同,底纹词通常具有以下特点:

  1. 视觉上与普通文本区分(通常有不同的背景色或边框)
  2. 可以被点击进行内容替换
  3. 可以包含提示性文字,指导用户输入什么内容
  4. 支持特殊的交互行为(如点击时自动聚焦)

本文将分享我使用 SlateJS 实现这一功能时遇到的挑战和解决方案。

比如豆包-帮我写作里,就用了这种底纹词效果:

image.png

技术选型

为什么选择 SlateJS?在评估了多种开源富文本编辑器框架后,我选择了 SlateJS 作为基础,主要基于以下考虑:

  • 高度可定制性:SlateJS 提供了一个轻量级的框架,允许我从头构建所需的编辑体验
  • React 集成:它与 React 生态系统完美集成,符合我的技术栈
  • 灵活的数据模型:它基于不可变数据结构,便于扩展和自定义节点类型
  • 活跃的社区:持续的维护和丰富的社区资源

需求分析

我的具体需求是:

  1. 创建一个支持模板化输入的文本编辑器
  2. 编辑器中支持两种特殊元素:文本占位符和下拉选择占位符
  3. 文本占位符需要满足:
    • 显示为特殊样式(蓝色背景)的内联元素
    • 点击空占位符时,光标应该定位到它的开始位置,并显示可见光标
    • 用户开始输入后,占位符样式应该保留,但显示用户输入的内容
    • 当有内容时,点击行为应与普通文本相同(光标出现在点击位置)
  4. 下拉选择占位符允许用户从预定义选项中选择

遇到的核心挑战

实现过程中,我遇到了以下关键挑战:

1. 光标控制问题

SlateJS 中的光标控制比普通 contentEditable 更复杂,特别是:

  • 如何在点击空占位符时将光标精确定位到开始位置
  • 如何确保光标在占位符内可见(视觉反馈)
  • 如何区分空占位符和已有内容的占位符的点击行为

2. 样式与结构问题

  • 如何创建既能保持 SlateJS 节点结构完整性,又能实现所需视觉效果的 DOM 结构
  • 如何处理占位符内文本的对齐和布局
  • 如何确保占位符在各种内容状态下保持一致的视觉效果

3. 类型系统挑战

  • SlateJS 的 TypeScript 类型定义与我的自定义节点结构不完全匹配
  • 如何正确扩展类型定义避免编译错误

解决方案设计

技术实现流程图

flowchart TD
    A[编辑器初始化] --> B[解析模板文本]
    B --> C[创建 SlateJS 文档结构]
    C --> D[渲染自定义节点]
    
    D --> E{节点类型?}
    E -->|文本占位符| F[渲染 TextPlaceholder]
    E -->|下拉占位符| G[渲染 DropdownPlaceholder]
    E -->|普通文本| H[渲染普通文本]
    
    F --> I{内容状态?}
    I -->|空| J[显示占位符样式]
    I -->|有内容| K[显示正常文本样式]
    
    J --> L[点击事件处理]
    L --> M[光标定位到开始]
    M --> N[确保光标可见]

核心解决方案

1. 复合 DOM 结构

为了解决占位符样式和光标控制问题,我设计了一个三层结构:

<span className="占位符容器">
  {isEmpty ? (
    <>
      {/* 1. 不可见的撑开元素 */}
      <span className="invisible">占位符文本</span>
      
      {/* 2. 可编辑区域 - 保留光标但隐藏文本 */}
      <span className="absolute 可编辑区域" ref={placeholderRef}>
        {children}
      </span>
      
      {/* 3. 占位符显示层 - 不可编辑但显示提示 */}
      <span className="absolute 提示层" contentEditable={false}>
        占位符文本
      </span>
    </>
  ) : (
    // 有内容时显示普通样式
    children
  )}
</span>

这种结构允许我:

  • 保持 SlateJS 的编辑功能(通过 children
  • 同时提供自定义的视觉效果(通过额外的显示层)
  • 控制光标行为(通过精确定位)

2. 增强光标控制

为解决光标显示问题,我结合使用了 SlateJS API 和原生 DOM Selection API:

const forceCursorDisplay = useCallback(() => {
  try {
    if (isEmpty) {
        // 获取占位符的精确路径
        const path = ReactEditor.findPath(editor, element)
        // 创建精确的选区,定位到文本开始
        const point = { path: [...path, 0], offset: 0 }
        // 选择并聚焦
        Transforms.select(editor, {
          anchor: point,
          focus: point,
        })
        // 强制聚焦
        ReactEditor.focus(editor as unknown as ReactEditor)
    }
  } catch (error) {
    console.warn('Failed to force cursor display:', error)
  }
}, [isEmpty, editor, element])

3. 差异化点击行为

根据占位符内容状态,我实现了不同的点击行为:

const handlePlaceholderClick = useCallback((event: React.MouseEvent) => {
  // 只有在占位符为空时才设置光标到开始位置
  if (isEmpty && isSpecialPlaceholder) {
    event.preventDefault()
    event.stopPropagation()
    forceCursorDisplay()
  }
  // 当有内容时,不干预默认行为,光标会出现在点击位置
}, [isEmpty, isSpecialPlaceholder, forceCursorDisplay])

核心代码实现

1. 文本占位符组件的实现

const TextPlaceholder = ({ attributes, children, element }: RenderElementProps) => {
  // 检查占位符内容是否为空
  const isEmpty = !element.children[0]?.text
  const isSpecialPlaceholder = PLACEHOLDER_MARKERS[element.placeholder]
  const editor = useSlate()
  const placeholderRef = useRef<HTMLSpanElement>(null)
  const wrappedPlaceholder = `[${element.placeholder}]`

  // 光标控制函数
  const forceCursorDisplay = useCallback(() => {
    // ... (上述光标控制代码)
  }, [isEmpty, isSpecialPlaceholder, editor, element])

  // 点击事件处理
  const handlePlaceholderClick = useCallback((event: React.MouseEvent) => {
    // ... (上述点击处理代码)
  }, [isEmpty, isSpecialPlaceholder, forceCursorDisplay])

  return (
    <span
      {...attributes}
      className="relative inline-flex bg-blue-100 text-blue-700 rounded cursor-text"
      data-placeholder={element.placeholder}
      onClick={handlePlaceholderClick}
      style={{
        padding: '1px 6px',
        minHeight: '1.5em',
        lineHeight: 'normal',
        verticalAlign: 'middle',
      }}
    >
      {(isEmpty && isSpecialPlaceholder)
        ? (
          <>
            {/* 不可见的撑开元素 */}
            <span
              className="invisible whitespace-pre"
              style={{ userSelect: 'none', pointerEvents: 'none' }}
            >
              {wrappedPlaceholder}
            </span>
            
            {/* 可编辑区域 */}
            <span
              ref={placeholderRef}
              className="absolute inset-0"
              style={{
                paddingLeft: '6px',
                paddingTop: '3px',
                color: 'transparent',
                caretColor: '#0066FF',
                zIndex: 5,
              }}
            >
              {children}
            </span>
            
            {/* 占位符文本显示 */}
            <span
              contentEditable={false}
              className="absolute inset-0 flex items-center pb-[1px]"
              style={{
                color: '#6a92e4',
                paddingLeft: '6px',
                zIndex: 1,
                pointerEvents: 'none',
              }}
            >
              {wrappedPlaceholder}
            </span>
          </>
        )
        : (
          <span className="h-full inline-flex items-center">
            {children}
          </span>
        )}
    </span>
  )
}

2. 自定义 SlateJS 编辑器实现

const withPlaceholders = (editor: ReactEditor): ReactEditor => {
  const { isInline, isVoid, normalizeNode } = editor

  // 设置占位符为内联元素
  editor.isInline = (element) => {
    if (!element || typeof element !== 'object' || !('type' in element))
      return isInline(element)

    return (
      element.type === 'text-placeholder'
      || element.type === 'dropdown-placeholder'
      || isInline(element)
    )
  }

  // 重要:不将文本占位符设置为void元素,允许光标进入
  editor.isVoid = (element) => {
    if (!element || typeof element !== 'object' || !('type' in element))
      return isVoid(element)

    // 只有下拉占位符才设置为void元素
    return element.type === 'dropdown-placeholder' || isVoid(element)
  }

  // 自定义规范化逻辑
  editor.normalizeNode = (entry) => {
    // ... 确保文档结构有效的代码
    normalizeNode(entry)
  }

  return editor
}

遇到的坑和解决方法

1. 光标可见性问题

问题:最初,虽然光标被设置到了正确位置,但在视觉上不可见。

解决方案

  • 采用三层结构,分离内容和显示
  • 使用 caretColor 设置明显的光标颜色
  • 文本颜色设为 transparent 而不是 opacity: 0,保留光标可见性
  • 增加 zIndex 确保光标显示在最上层

2. 点击行为差异化

问题:最初所有占位符的点击行为都相同,无论其是否有内容。

解决方案

  • 检查占位符内容状态(isEmpty
  • 根据状态采用不同的事件处理策略
  • 空占位符:阻止默认事件,强制光标到开始位置
  • 有内容的占位符:保留默认点击行为

3. 类型系统问题

问题:SlateJS 的类型定义与我的自定义节点结构不完全匹配。

解决方案

  • 扩展 SlateJS 类型定义
  • 在必要位置使用类型断言
  • 为自定义元素创建明确的类型定义

最佳实践总结

  1. 组合使用 API:SlateJS 提供了强大的编辑器功能,但结合原生 DOM API 可以实现更精细的控制。

  2. 分层设计:将视觉表现和编辑功能分离,使用多层 DOM 结构实现复杂交互。

  3. 状态区分:明确区分不同状态(如空/非空),并为每种状态设计恰当的交互行为。

  4. 防御性编程:添加错误处理和回退策略,确保编辑器在各种边缘情况下仍能可靠工作。

  5. 性能考量

    • 减少不必要的 DOM 操作
    • 使用 useCallbackuseMemo 减少重渲染

总结

最终成功实现了既符合产品需求又提供良好用户体验的自定义占位符功能。虽然实现过程中遇到了一些挑战,但最终的解决方案既保持了 SlateJS 的强大编辑能力,又提供了符合特定需求的交互体验。

这个实现也展示了 SlateJS 的灵活性和可扩展性,为其他类似的自定义编辑器功能提供了参考。


本文基于实际项目经验,涉及技术可能随 SlateJS 版本更新而变化。代码示例已简化,实际应用中需根据项目需求进行调整。