超越input!基于contentediable实现github全局搜索组件:从光标定位到输入事件的全链路设计

感谢DevUI社区贡献者 bugbuliu 提供的优质好文!

🔍 背景:传统搜索的阵痛与突围

在某大型代码托管平台的日常运维中,我们经常收到这样的用户反馈:

"为什么不能像Github一样通过'repo:xxx lang:java'直接过滤结果?"

"每次搜索都要反复切换标签页,效率太低了!"

"输错一个字母就搜索失败,连个提示都没有..."

这些吐槽直指传统搜索的三大痛点:

  1. 维度单一:只能通过切换标签进行单项搜索
  2. 缺少智能:没有语法识别和输入提示
  3. 容错率低:完全依赖用户输入准确性

为此我们启动"搜索体验升级"专项,打在代码仓最新升级的搜索专项中,我们打造了一个支持搜索语法的智能输入组件。相比原有只能搜索项目/组织的单一模式,新版搜索组件实现了三大突破:

  • 多维度搜索:支持代码内容、仓库类型、描述、语言、文件路径等16+种搜索维度
  • 智能联想 :基于词法解析的suggestions提示系统,输入过程自动补全,给出智能搜索建议
  • 语法兼容 :支持"双引号包裹"、repo:codehub等25种高级搜索语法,并实现搜索语法的关键字高亮效果
能力维度 旧版搜索 新版组件
搜索维度 2个固定维度 16+可组合维度
输入提示 语法感知的上下文提示
搜索准确率 完全匹配 支持通配符/引号包裹/逻辑运算符
关键字高亮 自定义关键字高亮样式

新旧搜索交互对比图

下面将详细介绍将传统input输入框升级为contentediable实现的具备富文本渲染能力的「类编辑器」组件。让我们通过实现细节,一窥复杂输入组件的设计奥义。

💡核心架构:编辑器设计模式落地

MVC架构拆解

我们采用经典MVC模式搭建组件内核:

ts 复制代码
// 核心类结构
class SearchEditor {
    private model: DataModel;  // 数据模型
    private viewModel: ViewModel; // 视图模型
    private controller: Controller; // 事件控制器
}

双模型设计实现数据与视图解耦:

输入事件拦截机制

contentediable元素实现简易富文本编辑的思路:拦截用户输入/光标事件, 对输入的值进行处理,最后再渲染出来。

通过事件劫持实现精准控制:

支持14种输入场景处理,包括:

  • 字符输入
  • 中文输入法合成
  • 多光标选区操作
  • 键盘快捷键响应
  • 复制粘贴事件
  • 删除事件
  • 方向键事件(上下、左右)
  • 撤销重做等

以下是部分实现,更多输入事件处理请参考自行处理: Input Events Level 2

ts 复制代码
  // 输入事件拦截
  public onBeforeInput(event: InputEvent) {
    const target = event.target as HTMLElement;
    const eventType = event.inputType;

    if (event.inputType === 'historyUndo') {
      this.onUndo(event);
    }

    if (event.inputType === 'historyRedo') {
      this.onRedo(event);
    }

    event.preventDefault();
    event.stopPropagation();

    // 非重点关注的事件类型直接阻止
    if (!ALLOW_INPUT_TYPE.includes(eventType)) {
      event.preventDefault();
      return;
    }

    if (eventType === 'deleteContentBackward') {
      this.deleteContentBackward();
    }

    if (eventType === 'deleteContentForward') {
      this.deleteContentForward();
    }

    if (eventType === 'insertText') {
      this.insertText(event);
    }

    if (eventType === 'insertCompositionText') {
      this.scrollCursorVisible();
      return;
    }
  }
  
  // 插入字符:拦截输入后需要手动拼接字符串
  insertValue(text: string, anchorOffset?: number, focusOffset?: number) {
    let startOffset = anchorOffset ?? this.cursorInfo.anchorOffset;
    let endOffset = focusOffset ?? this.cursorInfo.focusOffset;
    let newCursorOffset = startOffset + text.length;
    const { isCollapsed } = this._selection;

    if (!isCollapsed) {
      startOffset = Math.min(startOffset, endOffset);
      endOffset = Math.max(anchorOffset ?? this.cursorInfo.anchorOffset, endOffset);
      newCursorOffset = startOffset + text.length;
    }
    if (this.valueHistory.isEmpty()) {
      this.initUndoHistory();
    }
    this.value = this.value.slice(0, startOffset) + text + this.value.slice(endOffset);
    if (this.value.length > this.maxLength) {
      this.value = this.value.slice(0, this.maxLength);
    }
    this.updateSourceCursorInfo(newCursorOffset);
    this.cacheUndoHistory();
    this.updateCursor();
    this.update();
  }

🎯组件设计:配置化与扩展性

页面结构设计

从形态上属于一个输入框,左右两边都保留了插槽扩展,同时也会提供默认的插槽内容,中间就是搜索框的输入和渲染区域,同时也保留了一个token插槽支持自定义token节点

html 复制代码
<div class="multi-search-wrapper" [ngClass]="{ disabled: config.disabled }">
  <div class="search-input">
    <span class="search-left-template" tabindex="-1">
      <ng-container *ngIf="inputLeftTemplate" [ngTemplateOutlet]="inputLeftTemplate"></ng-container>
      <d-icon *ngIf="!inputLeftTemplate" class="default-search-icon" [icon]="'icon-search-new'" (click)="enterEvent($event)"></d-icon>
    </span>
    <div class="search-content-wrapper" #contentScrollContainer>
      <div
        #searchInputContentEdiable
        class="search-input-content"
        [spellcheck]="config.spellcheck"
        [attr.contenteditable]="contenteditable"
        (copy)="handleCopy($event)"
        (click)="handleClick($event)"
        (paste)="handlePaste($event)"
        (input)="handleInput($event)"
        (keydown)="handleKeydown($event)"
        (keydown.enter)="enterEvent($event)"
        (beforeinput)="onBeforeInput($event)"
        (compositionupdate)="compositionupdate($event)"
        (compositionend)="handleCompositionend($event)"
        (compositionstart)="handleCompositionstart($event)"
      >
        <!-- 渲染模板 -->
        <div class="text-line" #inputRow id="search-input-div">
          <span class="composition-input-first" *ngIf="!tokenArray?.length"></span>
          <ng-container *ngFor="let token of tokenArray">
            <ng-container *ngIf="token?.type === tokenType.keyword">
              <span
                class="search-input-keyword search-input-node search-token"
                [ngClass]="{ 'token-empty-value': !token?.value }"
                #inputNode
                [ngStyle]="config.tokenStyle?.keywords"
              >
                <span class="search-token-keyword">{{ token?.keyword }}</span>
                <span class="search-token-separator">:</span>
                <span class="search-token-text">{{ token?.value }}</span>
              </span></ng-container
            ><span
              *ngIf="token?.type === tokenType.split"
              #inputNode
              class="search-split-text search-input-text search-input-node"
              [innerText]="token.value"
              [ngStyle]="config.tokenStyle?.split"
            ></span
            ><span
              *ngIf="token?.type === tokenType.connect"
              #inputNode
              class="search-token token-bg-connected search-input-node"
              [ngStyle]="config.tokenStyle?.connect"
            >
              <span class="search-token-connected">{{ token.keyword }}</span></span
            ><span
              *ngIf="token?.type === tokenType.text"
              #inputNode
              class="search-token token-bg-default search-input-text search-input-node"
              [ngStyle]="config.tokenStyle?.text"
              >{{ token.value }}</span
            >
            <ng-container
              *ngIf="tokenTemplate"
              [ngTemplateOutlet]="tokenTemplate"
              [ngTemplateOutletContext]="{ token: token }"
            ></ng-container>
          </ng-container>
        </div>
      </div>
      <!-- 自定义光标 -->
      <span #customCursor class="custom-cursor"></span>
    </div>
    <span class="search-right-template">
      <ng-container *ngIf="inputRightTemplate" [ngTemplateOutlet]="inputRightTemplate"></ng-container>
      <d-icon
        *ngIf="!inputRightTemplate && config.showClear && value"
        class="default-clear-icon"
        [icon]="'icon-ipd-close1'"
        (click)="clearAll()"
      ></d-icon>
    </span>
    <!-- placeholder -->
    <span *ngIf="!tokenArray?.length && !isComposing" class="input-placeholder-text" contenteditable="false">{{ config.placeholder }}</span>
  </div>
</div>

组件输入参数说明

属性名 类型 默认值 描述说明
value string - 绑定的输入值
tokens Array<Token> - 解析后的token数组
placeholder string '' 输入框占位符文本
keywords Array<string> [] 可识别的搜索关键字列表(如: ["repo:", "lang:", "path:"])
connectWords Array<string> [] 连接词列表(如: ["AND", "OR", "NOT"])
tokenStyle TokenStyle - Token样式配置对象,包含颜色/圆角/字体等样式属性
showClear boolean false 是否显示清除按钮
caseSensitive boolean false 是否启用大小写敏感模式
spellcheck boolean false 是否启用浏览器拼写检查
disabled boolean false 是否禁用输入框
maxLength number 2000 允许输入的最大字符长度
tokenParser (value: string, options: ...) => Array<Token> TokenParser.tokenizer Token解析函数,可通过传入自定义函数实现特殊解析规则

组件输出事件与插槽

名称/插槽 类型 描述说明
keyDownOrKeyUp EventEmitter<{event: KeyboardEvent}> 键盘按下或释放时触发(带原生事件对象)
valueChange EventEmitter<string> 输入框内容变化时触发(传递最新值)
searchEvent EventEmitter<void> 触发搜索行为时发射(如回车键按下)
suggessionEvent EventEmitter<Array<string>> 展示搜索建议时触发(传递建议列表)
afterComponentInit EventEmitter<void> 组件完成初始化后触发
currentKeywordSuggestionEvent EventEmitter<string> 检测到关键字建议时触发(传递建议文本)
inputLeftTemplate TemplateRef 输入框左侧自定义模板插槽
inputRightTemplate TemplateRef 输入框右侧自定义模板插槽
tokenTemplate TemplateRef<{token: Token}> 自定义Token渲染模板(可访问Token上下文)

组件暴露API:

当前位置插入字符:public insertValue(text: string, anchorOffset?: number, focusOffset?: number): void;

末尾追加字符:public insertValueEnd(text: string): void;

💫关键技术:光标的魔鬼细节

光标与选区前置基础知识

  • 在HTML里面,光标是一个对象,
  • selection,是一个选区,可通过window.getSelection()获取
  • range,是一个片段区域,有开始点和结束点
  • isCollapsed 用来判断选区的起始点与终点是否在同一个位置,即当前选区是光标所在的位置。
  • 光标在闪,其实只是开始和结束点重叠了
  • 设置光标位置:Selection.collapse(parentNode: Node, offset?: number)
  • anchor (锚点) 与 focus (焦点) 的概念,这里 anchor 代表选择文本时鼠标按下开始选择的位置,focus 表示选择文本结束时,鼠标所处的位置,这里的 focus 与元素的 focus 事件不是一个概念。
  • anchorNode 代表锚点所在的节点,focusNode 代表焦点所在的节点
  • anchorOffsetfocusOffset ,他们的计算方式都是从左到右计算到选区的边界,总共有多少个字符

感兴趣的朋友可以参考这篇文章《点亮富文本编辑器的魔力:Selection与Range解密)》

富光标维护方案

光标映射三定律

  1. 用户操作视图光标 → 更新模型逻辑位置
  2. 模型内容变更 → 重新渲染 → 恢复视觉光标
  3. 跨token光标需要计算字符偏移量

用户操作视图选区变化时,导致视图光标变化,需要同步更新原值光标;

用户输入/删除文本时,导致原值及其光标变化,需要更新视图,再异步更新视图光标;

光标映射算法

实现原始值光标 ↔ 视图光标的双向映射是最大挑战:

ts 复制代码
// 原始值偏移量 → 视图位置
  getCursorNodeIndex(node, targetNode, targetOffset, text) {
    if (node.nodeType === Node.COMMENT_NODE || text.offset !== undefined) {
      return text;
    }
    if (node === targetNode && targetOffset === 0) {
      return {
        matchText: text,
        offset: text.length,
      };
    }
    if (node.nodeType === Node.TEXT_NODE) {
      if (node === targetNode && targetOffset === 0) {
        return {
          matchText: text,
          offset: text.length,
        };
      }
      for (let i = 0; i < node.textContent.length; i++) {
        text += node.textContent[i];
        if (node === targetNode && i === targetOffset - 1) {
          return {
            matchText: text,
            offset: text.length,
          };
        }
      }
      return text;
    } else {
      for (let i = 0; i < node.childNodes.length; i++) {
        text = this.getCursorNodeIndex(node.childNodes[i], targetNode, targetOffset, text);
        if (text.offset !== undefined) {
          return text;
        }
      }
    }
    return text;
  }
  
    // 富文本的光标映射回原始文本
  updateCursorInfoFromRich() {
    if (this.value === '') {
      this.updateSourceCursorInfo(0);
      return;
    }
    const { anchorNode, anchorOffset, focusNode, focusOffset, isCollapsed } = this._selection;
    let anchorIndex = this.getCursorNodeIndex(this.inputRow.nativeElement, anchorNode, anchorOffset, '');
    if (typeof anchorIndex === 'string') {
      anchorIndex = {
        offset: this.value.length,
      };
    }
    if (isCollapsed) {
      this.updateSourceCursorInfo(anchorIndex?.offset || 0);
    } else {
      let focusIndex = this.getCursorNodeIndex(this.inputRow.nativeElement, focusNode, focusOffset, '');
      if (typeof focusIndex === 'string') {
        focusIndex = {
          offset: 0,
        };
      }
      this.updateSourceCursorInfo(anchorIndex.offset, focusIndex?.offset);
    }
    this.scrollCursorVisible();
    this.currentKeywordSuggestion();
  }

👨‍🚀语法解析:关键字匹配

对用户的输入进行关键字划分是高亮渲染的关键,我们需要将用户输入的value进行解析,形成tokens数组, 最后才能通过html高亮渲染出来。

这里我们使用了简单的正则表达式进行的词法解析,能够满足基础的key: word键值对匹配。如果有需要满足更复杂的搜索语法能力支持,可以对其进行扩展。

ts 复制代码
// 词法解析流程
export const KEYWORD_REG = /([\w\u4e00-\u9fa5]+):((".*?"[ \t]+)|('.*?'[ \t]+)|(\S*[ \t]+)|)|(".*?"[ \t]+)|(\S+[ \t]+)/g; // 解析 key:value,同时支持双引号或单引号包裹的值(允许值中有空格)
export const SPLIT_START = /^\s+/g;
export const SPLIT_END = /\s+$/g;


export class Token {
  value: string;
  type: TokenType;
  keyword: string;
  constructor(type: TokenType, value: string, keyword: string = '') {
    this.type = type;
    this.keyword = keyword;
    this.value = value;
  }
}

export class TokenParser {
  /**
   * token解析器
   * @param value
   * @returns tokens
   */
  static tokenizer = (value: string, options: HubMultiSearchConfig): Array<Token> => {
    if(value === '') {
      return [];
    }
    if (value.trim() === '') {
      return [new Token(TokenType.split, value)];
    }
    let match = value.match(SPLIT_START);
    let startSplitToken;
    if(match) {
      startSplitToken = new Token(TokenType.split, match[0]);
    }
    let valueEndAppend = false;
    if (value[value.length - 1] !== ' ') {
      value += ' ';
      valueEndAppend = true;
    }
    let keywordObject = this.parseStringToObject(value);
    let tokens = this.decorateToken(keywordObject, options);
    if (valueEndAppend) {
      tokens.pop();
    }
    if (startSplitToken) {
      tokens.unshift(startSplitToken);
    }
    return tokens;
  };

  static decorateToken(keywordObject, options: HubMultiSearchConfig): Array<Token> {
    let result = [];
    for (let i = 0; i < keywordObject.length; i++) {
      let tokenItem = keywordObject[i];
      if (typeof tokenItem === 'object') {
        this.handleObjectToken(tokenItem, result, options.keywords);
      } else {
        this.handleNonObjectToken(tokenItem, result);
      }
    }
    return result;
  }

  static handleObjectToken(tokenItem, result, keywords) {
    let key = Object.keys(tokenItem)[0];
    if (keywords.includes(key)) {
      result.push(new Token(TokenType.keyword, tokenItem[key].trim(), key));
    } else {
      result.push(new Token(TokenType.text, `${key}:${tokenItem[key].trim()}`));
    }
    this.handleSplitToken(tokenItem[key], result);
    return result;
  }

  static handleNonObjectToken(tokenItem, result) {
    result.push(new Token(TokenType.text, tokenItem.trim()));
    this.handleSplitToken(tokenItem, result);
    return result;
  }

  static handleSplitToken(tokenValue, result) {
    let match = tokenValue.match(SPLIT_END);
    if (match) {
      result.push(new Token(TokenType.split, match[0]));
    }
  }

  static parseStringToObject(input, regex = KEYWORD_REG) {
    const result = [];
    let match;

    while ((match = regex.exec(input)) !== null) {
      const key = match[1];
      let value = match[2];
      if (!key) {
        result.push(match[0]);
      } else {
        result.push({ [key]: value });
      }
    }
    return result;
  }
}

🧪经验总结:踩坑启示录

在开发过程中,遇到的一些问题:

光标同步异步化 :视图更新后通过requestAnimationFrame延迟处理光标定位

中文输入问题

直接监听input事件导致中文输入法吞字,合成输入法会直接修改dom ✅ 解决方案:通过compositionstart/end标志位过滤

快速输入时光标随机跳动

✅ 定位原因:模型更新与渲染不同步

🛠️ 修复方案:引入更新队列+RAF节流

光标位置超出视口无法自动跟随

getBoundingClientRect实现自定义光标 + scrollIntoView滚动

撤销重做事件实现 : beforinput + execCommand + undoManager 手动记录堆栈并管理

加入我们

DevUI团队重磅推出~前端智能化场景解决方案MateChat,为项目添加智能化助手~

源码:gitcode.com/DevCloudFE/...(欢迎star~)

官网:matechat.gitcode.com

相关推荐
夕水13 分钟前
这个提升效率宝藏级工具一定要收藏使用
前端·javascript·trae
会飞的鱼先生27 分钟前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js
斯~内克41 分钟前
前端浏览器窗口交互完全指南:从基础操作到高级控制
前端
Mike_jia1 小时前
Memos:知识工作者的理想开源笔记系统
前端
前端大白话1 小时前
前端崩溃瞬间救星!10 个 JavaScript 实战技巧大揭秘
前端·javascript
loveoobaby1 小时前
Shadertoy着色器移植到Three.js经验总结
前端
蓝易云1 小时前
在Linux、CentOS7中设置shell脚本开机自启动服务
前端·后端·centos
浩龙不eMo1 小时前
前端获取环境变量方式区分(Vite)
前端·vite
一千柯橘1 小时前
Nestjs 解决 request entity too large
javascript·后端
土豆骑士2 小时前
monorepo 实战练习
前端