You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在基于Slate.js开发的笔记应用中实现类Twitter的股票提及功能?

实现Slate.js股票代码提及节点的可行方案

首先明确:用normalizeNode不是错误思路,但确实不是最适合这个场景的方案。normalizeNode的核心作用是维护文档结构的合法性(比如纠正嵌套错误、补全必填属性),而你要做的是将用户输入的特定文本实时转换为自定义节点,这属于交互层面的输入处理,用onChange钩子结合文本匹配,或者采用「实时高亮+触发转换」的模式会更合适,也更符合用户的输入体验。

下面给你两种可行的实现方案:

方案一:实时自动转换(输入完成后自动替换)

这种方案会在用户输入暂停时(比如选区折叠),自动检测文本中的股票代码并转换为提及节点。

步骤1:定义提及节点类型

首先要注册一个mention类型的节点,用于渲染股票代码:

// 定义Mention节点配置
const MentionNode = {
  type: 'mention',
  inline: true, // 标记为行内节点,和文本在同一行显示
  renderElement: (props) => {
    const { symbol } = props.element;
    // 自定义样式,比如蓝色背景、圆角等
    return (
      <span 
        className="mention-node" 
        data-symbol={symbol}
        style={{ backgroundColor: '#e8f4fd', padding: '2px 4px', borderRadius: '4px' }}
      >
        ${symbol}
      </span>
    );
  },
};

步骤2:扩展编辑器的onChange钩子

通过withPlugins创建一个编辑器扩展,在onChange中检测并转换文本:

const withStockMention = (editor) => {
  const { onChange } = editor;

  editor.onChange = () => {
    // 先执行原有的onChange逻辑
    onChange();

    // 只在选区折叠时处理(避免用户选中文本时误触发)
    if (editor.selection && Range.isCollapsed(editor.selection)) {
      // 查找所有包含股票代码的文本节点
      const [match] = Editor.nodes(editor, {
        match: (n) => Text.isText(n) && /\$[a-zA-Z]{1,6}/.test(n.text),
        mode: 'lowest', // 只找最底层的文本节点
      });

      if (match) {
        const [textNode, nodePath] = match;
        const fullText = textNode.text;
        const regex = /\$([a-zA-Z]{1,6})/g;
        let matchResult;
        let offset = 0;

        // 遍历所有匹配的股票代码
        while ((matchResult = regex.exec(fullText)) !== null) {
          const symbol = matchResult[1];
          const startPos = matchResult.index;
          const endPos = startPos + symbol.length + 1; // 包含$符号的总长度

          // 用Transact包裹操作,确保所有修改作为一个原子操作
          Transact(editor, () => {
            // 分割文本节点,把匹配的部分单独拆出来
            Editor.splitNode(editor, nodePath, { at: startPos });
            Editor.splitNode(editor, [nodePath[0], 1], { at: endPos - startPos });

            // 删除拆分出来的匹配文本
            Editor.removeNodes(editor, { at: [nodePath[0], 1] });

            // 插入自定义的mention节点
            Editor.insertNodes(editor, {
              type: 'mention',
              symbol: symbol,
              children: [{ text: '' }], // Slate节点必须包含children数组
            }, { at: [nodePath[0], 1] });
          });
        }
      }
    }
  };

  return editor;
};

步骤3:注册插件到编辑器

在初始化Slate编辑器时,把这两个插件加进去:

const editor = useSlate({
  plugins: [withStockMention, MentionNode],
  // 其他配置...
});

方案二:实时高亮+手动触发转换(更贴合Twitter体验)

这种方案不会自动转换,而是在用户输入$后实时高亮匹配的文本,当用户按下空格/回车时再转换为提及节点,避免打断输入流程。

步骤1:添加文本高亮逻辑

用Slate的decorate函数标记匹配的文本范围,然后在renderLeaf中渲染高亮样式:

// 自定义decorate函数,标记需要高亮的股票代码
const decorateMention = ([node, path]) => {
  if (!Text.isText(node)) return [];
  const highlightRanges = [];
  const regex = /\$[a-zA-Z]{1,6}/g;
  let match;

  while ((match = regex.exec(node.text)) !== null) {
    highlightRanges.push({
      anchor: { path, offset: match.index },
      focus: { path, offset: match.index + match[0].length },
      type: 'stock-mention-preview', // 自定义标记类型
    });
  }

  return highlightRanges;
};

// 自定义renderLeaf,处理高亮样式
const renderCustomLeaf = (props) => {
  const { leaf, attributes, children } = props;
  if (leaf.type === 'stock-mention-preview') {
    return (
      <span 
        {...attributes} 
        style={{ backgroundColor: '#e8f4fd', borderBottom: '1px solid #1da1f2' }}
      >
        {children}
      </span>
    );
  }
  return <span {...attributes}>{children}</span>;
};

步骤2:监听键盘事件触发转换

当用户按下空格或回车时,检查当前选区是否在高亮的文本上,然后执行转换:

const handleKeyDown = (event) => {
  const editor = useSlate();
  // 只在按下空格或回车时触发
  if ([' ', 'Enter'].includes(event.key)) {
    const [match] = Editor.nodes(editor, {
      match: (n) => Text.isText(n) && /\$[a-zA-Z]{1,6}$/.test(n.text),
      mode: 'lowest',
    });

    if (match) {
      event.preventDefault(); // 阻止默认的空格/回车行为
      const [textNode, nodePath] = match;
      const matchResult = textNode.text.match(/\$([a-zA-Z]{1,6})$/);
      if (!matchResult) return;

      const symbol = matchResult[1];
      const startPos = matchResult.index;

      Transact(editor, () => {
        // 分割并删除匹配的文本
        Editor.splitNode(editor, nodePath, { at: startPos });
        Editor.removeNodes(editor, { at: [nodePath[0], 1] });

        // 插入mention节点
        Editor.insertNodes(editor, {
          type: 'mention',
          symbol: symbol,
          children: [{ text: '' }],
        }, { at: [nodePath[0], 1] });

        // 插入空格(如果是空格触发的话)
        if (event.key === ' ') {
          Editor.insertNodes(editor, { text: ' ' });
        }
      });
    }
  }
};

步骤3:关联到编辑器组件

把decorate、renderLeaf和键盘事件绑定到编辑器:

<Slate editor={editor} initialValue={initialValue}>
  <Editable
    decorate={decorateMention}
    renderLeaf={renderCustomLeaf}
    onKeyDown={handleKeyDown}
    // 其他配置...
  />
</Slate>

为什么不推荐用normalizeNode?

normalizeNode的触发时机是文档结构发生变化且不符合预设规则时,它的设计目的是「修正错误」,而不是「处理用户输入交互」。如果用它来转换文本节点,会有两个问题:

  1. 容易触发无限循环:比如你在normalizeNode中删除文本节点并插入mention节点,会再次触发normalizeNode,导致死循环。
  2. 打断用户输入:用户正在输入$AAP还没输完$AAPL时,normalizeNode可能就触发转换,打断输入流程。

所以更推荐用上面两种方案,尤其是方案二,更符合用户的输入习惯。

内容的提问来源于stack exchange,提问作者Filuren

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.29 11:22:58