模版转换功能增强

MarkdownButton 组件增强功能实现

在 Dify 项目的模板转换节点中,希望通过配置属性实现以下功能:

  1. 一次性点击按钮 :按钮点击一次后自动禁用,防止重复操作
  2. 自定义样式 :支持通过配置属性自定义按钮的外观(背景色、文字色、圆角等)
  3. 按钮唯一性标识 :支持为相同内容的按钮设置不同的点击状态

文件路径:app\components\base\markdown-blocks\button.tsx

实现方案

1. 核心技术思路

实现从模板引擎里面读取写法,通过判断自定义属性来对按钮进行处理,当配置只点击一次自定义属性时,通过缓存储存状态,实现持久化

问题分析 :React 组件重新渲染时,组件内部的 useState / useRef 状态会丢失,导致按钮点击状态无法保持。

解决方案 :使用 模块级别的 Set 来跨组件实例持久化点击状态。

2. 新增配置属性

属性 类型 说明 示例 data-once boolean 是否只能点击一次 true / false data-id string 按钮唯一标识 btn-submit data-bg-color string 自定义背景颜色 #ff5733 data-text-color string 自定义文字颜色 #ffffff data-border-radius string 自定义边框圆角 8px / 50% data-padding string 自定义内边距 10px 20px data-custom-class string 自定义 CSS 类名 my-button

3. 状态持久化机制

// 模块级别变量,生命周期与应用相同
const clickedButtons = new Set<string>()

// 生成稳定的按钮唯一标识
const buttonId = customId 
  ? `custom-${customId}`
  : `${message || buttonContent}-${variant || 'default'}-${size || 'medium'}-${node.position?.start?.offset || node.position?.start?.line || Math.random().toString(36).substr(2, 9)}`

按钮标识生成优先级:

  1. 自定义 ID (最高优先级):用户通过 data-id 属性指定
  2. 文档位置 :使用按钮在文档中的位置信息
  3. 随机数 (兜底):确保唯一性

核心代码实现

1. 完整组件代码

/**
 * MarkdownButton 组件
 * 
 * 用于渲染 Markdown 中的按钮元素,支持多种配置属性和交互功能。
 * 
 * 支持的属性(通过 data-* 属性传递):
 * - data-message: 点击按钮时发送的消息内容
 * - data-variant: 按钮样式变体(primary/secondary/ghost 等)
 * - data-link: 链接地址,点击时在新窗口打开
 * - data-size: 按钮尺寸(small/medium/large)
 * - data-once: 是否只能点击一次(true/false)
 * - data-id: 按钮唯一标识(用于区分相同内容的按钮)
 * - data-bg-color: 自定义背景颜色
 * - data-text-color: 自定义文字颜色
 * - data-border-radius: 自定义边框圆角
 * - data-padding: 自定义内边距
 * - data-custom-class: 自定义 CSS 类名
 * 
 * 示例用法:
 * <button data-message="主要" data-variant="primary" data-once="true">主要</button>
 * <button data-message="链接" data-link="https://example.com">跳转链接</button>
 * <button data-message="自定义" data-bg-color="#ff5733" data-text-color="white">自定义按钮</button>
 */

import { useState, useCallback, useRef, useEffect } from 'react'
import Button from '@/app/components/base/button'
import { useChatContext } from '@/app/components/base/chat/chat/context'
import { cn } from '@/utils/classnames'
import { isValidUrl } from './utils'

/**
 * 模块级别的已点击按钮集合
 * 用于跨组件实例持久化按钮点击状态,防止组件重新渲染时状态丢失
 */
const clickedButtons = new Set<string>()

/**
 * MarkdownButton 组件
 * @param {object} node - ReactMarkdown 解析后的节点对象
 * @param {object} node.properties - 节点的属性集合(包含所有 data-* 属性)
 * @param {array} node.children - 节点的子节点
 * @returns {JSX.Element} 渲染后的按钮组件
 */
const MarkdownButton = ({ node }: any) => {
  // 从 ChatContext 获取发送消息的回调函数
  const { onSend } = useChatContext()

  // 从节点属性中提取配置参数
  const variant = node.properties.dataVariant       // 按钮样式变体
  const message = node.properties.dataMessage      // 点击发送的消息
  const link = node.properties.dataLink            // 跳转链接
  const size = node.properties.dataSize            // 按钮尺寸
  const customClass = node.properties.dataCustomClass || node.properties['data-custom-class'] || ''  // 自定义 CSS 类名
  
  // 自定义样式属性
  const bgColor = node.properties.dataBgColor || node.properties['data-bg-color']           // 背景颜色
  const textColor = node.properties.dataTextColor || node.properties['data-text-color']     // 文字颜色
  const borderRadius = node.properties.dataBorderRadius || node.properties['data-border-radius'] // 边框圆角
  const padding = node.properties.dataPadding || node.properties['data-padding']           // 内边距
  
  // 是否启用一次性点击功能(支持驼峰式和短横线式属性名)
  const once = node.properties.dataOnce === 'true' || node.properties.dataOnce === true || 
               node.properties['data-once'] === 'true' || node.properties['data-once'] === true

  // 按钮显示文本
  const buttonContent = node.children[0]?.value || ''
  
  // 获取自定义按钮 ID(用于区分相同内容的按钮)
  const customId = node.properties.dataId || node.properties['data-id']
  
  /**
   * 生成按钮唯一标识
   * 优先级:自定义 ID > (消息内容/按钮文本 + 样式变体 + 尺寸 + 文档位置/随机数)
   */
  const buttonId = customId 
    ? `custom-${customId}`
    : `${message || buttonContent}-${variant || 'default'}-${size || 'medium'}-${node.position?.start?.offset || node.position?.start?.line || Math.random().toString(36).substr(2, 9)}`

  // 按钮禁用状态(从持久化集合中初始化)
  const [isDisabled, setIsDisabled] = useState(clickedButtons.has(buttonId))
  
  // 按钮 DOM 引用(备用)
  const buttonRef = useRef<HTMLButtonElement>(null)

  /**
   * 组件挂载时检查按钮是否已被点击过
   * 确保页面刷新或组件重新渲染后,按钮状态仍然正确
   */
  useEffect(() => {
    if (clickedButtons.has(buttonId)) {
      setIsDisabled(true)
    }
  }, [buttonId])

  /**
   * 处理按钮点击事件
   * - 如果按钮已被点击过(启用了一次性点击),直接返回
   * - 如果有链接且有效,在新窗口打开链接
   * - 如果有消息,发送消息
   * - 如果启用了一次性点击,记录点击状态并禁用按钮
   */
  const handleClick = useCallback(() => {
    // 如果按钮已被点击过,直接返回
    if (clickedButtons.has(buttonId)) return

    // 处理链接跳转
    if (link && isValidUrl(link)) {
      window.open(link, '_blank')
    } 
    // 处理消息发送
    else if (message) {
      onSend?.(message)
    }

    // 如果启用了一次性点击,记录状态并禁用
    if (once) {
      clickedButtons.add(buttonId)
      setIsDisabled(true)
    }
  }, [link, message, onSend, once, buttonId])

  // 渲染按钮组件
  return (
    <Button
      variant={variant}                    // 样式变体
      size={size}                          // 尺寸
      disabled={isDisabled}                // 禁用状态
      className={cn('!h-auto min-h-8 select-none whitespace-normal !px-3', customClass)} // 基础样式 + 自定义类名
      onClick={handleClick}                // 点击事件处理
      style={{
        // 应用自定义样式(仅在属性存在时)
        ...(bgColor && { backgroundColor: bgColor }),
        ...(textColor && { color: textColor }),
        ...(borderRadius && { borderRadius: borderRadius }),
        ...(padding && { padding: padding }),
      }}
    >
      <span className="text-[13px]">{buttonContent}</span>
    </Button>
  )
}

// 设置组件显示名称(便于调试)
MarkdownButton.displayName = 'MarkdownButton'

export default MarkdownButton

2. 关键实现细节

  1. 属性读取
// 是否启用一次性点击功能(支持驼峰式和短横线式属性名)
  const once = node.properties.dataOnce === 'true' || node.properties.dataOnce === true || 
               node.properties['data-once'] === 'true' || node.properties['data-once'] === true
  1. 点击处理逻辑
const handleClick = useCallback(() => {
  // 如果按钮已被点击过,直接返回
  if (clickedButtons.has(buttonId)) return

  // 处理链接跳转
  if (link && isValidUrl(link)) {
    window.open(link, '_blank')
  } 
  // 处理消息发送
  else if (message) {
    onSend?.(message)
  }

  // 如果启用了一次性点击,记录状态并禁用
  if (once) {
    clickedButtons.add(buttonId)
    setIsDisabled(true)
  }
}, [link, message, onSend, once, buttonId])

使用示例

1. 一次性点击按钮

<button data-message="确认提交" data-variant="primary" data-once="true">确认提交</button>

2. 多个相同按钮区分

<button data-message="主要" data-variant="primary" 
data-once="true" data-id="btn-1">主要</button>
<button data-message="主要" data-variant="primary" 
data-once="true" data-id="btn-2">主要</button>

3. 自定义样式按钮

<button 
  data-message="自定义按钮" 
  data-bg-color="#27ae60" 
  data-text-color="#ffffff" 
  data-border-radius="8px"
  data-padding="12px 24px"
>
  绿色按钮
</button>

技术亮点

1 状态持久化方案

使用模块级别的 Set 实现跨组件实例的状态共享,解决了 React 组件重新渲染时状态丢失的问题。

2 灵活的属性配置

支持驼峰式( dataOnce )和短横线式( data-once )两种属性命名方式,提供了灵活的配置体验。

3 唯一标识生成策略

通过多层级的唯一标识生成策略,确保即使是相同内容的按钮也能正确区分。

4 渐进式增强

新增功能完全向后兼容,不影响原有按钮的使用方式。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容