超越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

相关推荐
江城开朗的豌豆2 分钟前
Vue中key值的秘密:为什么这个小东西能让列表渲染更聪明?
前端·javascript·vue.js
tager4 分钟前
为什么推荐使用Whistle而不是Fiddler、Charles!🤗
前端·fiddler·charles
江城开朗的豌豆12 分钟前
Vue 3.0真香!用了半年后我来告诉你为什么这么爽
前端·javascript·vue.js
前端工作日常13 分钟前
我理解的 npm 作用域包
前端
小小小小宇13 分钟前
移动端软键盘弹出问题
前端
小小小小宇13 分钟前
前端常见浏览器兼容性问题
前端
小小小小宇7 小时前
前端并发控制管理
前端
小小小小宇8 小时前
前端SSE笔记
前端
小小小小宇8 小时前
前端 WebSocket 笔记
前端
小小小小宇9 小时前
前端visibilitychange事件
前端