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

如何在Angular中实现社交平台式#/@标签高亮及联想列表功能

Angular 实现#/@文本高亮+联想列表方案

1. 选用contenteditable容器替代原生input

原生input无法内嵌span这类富文本元素,必须用contenteditable="true"的div作为输入容器。在Angular组件中可以这样定义:

<div 
  #editableContainer
  contenteditable="true"
  class="input-container"
  (input)="onInput($event)"
  (keydown)="onKeyDown($event)"
></div>
<!-- 联想列表容器 -->
<div #suggestionList class="suggestion-box" *ngIf="showSuggestions">
  <div *ngFor="let item of suggestions" (click)="selectSuggestion(item)">{{item.name}}</div>
</div>

2. 实时解析文本并高亮#/@内容

监听输入事件,每次输入后解析文本中的#和@匹配项,将其替换为带样式的span。关键是处理光标位置,避免替换后光标错位:

@ViewChild('editableContainer') editableContainer!: ElementRef<HTMLDivElement>;
private lastCursorPosition: number = 0;

onInput(event: Event) {
  const container = this.editableContainer.nativeElement;
  // 保存当前光标位置
  this.lastCursorPosition = this.getCursorPosition(container);
  // 解析文本,替换#/@为高亮span
  const text = container.innerText;
  const formattedText = text.replace(/(#\w+)|(@\w+)/g, (match) => {
    const className = match.startsWith('#') ? 'hashtag' : 'mention';
    return `<span class="${className}">${match}</span>`;
  });
  // 重新设置内容
  container.innerHTML = formattedText;
  // 恢复光标位置
  this.setCursorPosition(container, this.lastCursorPosition);
}

// 获取光标位置的工具方法
private getCursorPosition(element: HTMLElement): number {
  let position = 0;
  const selection = window.getSelection();
  if (selection?.focusNode === element) {
    position = selection.focusOffset;
  } else if (selection?.focusNode?.parentElement === element) {
    position = Array.from(element.childNodes).indexOf(selection.focusNode) + selection.focusOffset;
  }
  return position;
}

// 设置光标位置的工具方法
private setCursorPosition(element: HTMLElement, position: number): void {
  const selection = window.getSelection();
  const range = document.createRange();
  let currentNode: Node | null = element.firstChild;
  let currentLength = 0;

  while (currentNode) {
    if (currentNode.nodeType === Node.TEXT_NODE) {
      if (currentLength + currentNode.length >= position) {
        range.setStart(currentNode, position - currentLength);
        range.collapse(true);
        selection?.removeAllRanges();
        selection?.addRange(range);
        break;
      }
      currentLength += currentNode.length;
    }
    currentNode = currentNode.nextSibling;
  }
}

对应的CSS样式:

.hashtag {
  color: #1da1f2;
  cursor: pointer;
}
.mention {
  color: #1da1f2;
  cursor: pointer;
}
.input-container {
  border: 1px solid #ccc;
  padding: 8px;
  min-height: 40px;
}

3. 联想列表的触发与定位

当用户输入#或@时,触发联想请求,根据输入的关键词过滤数据,并将联想框定位在光标下方:

showSuggestions = false;
suggestions: Array<{name: string, id: string}> = [];
currentTrigger: '#' | '@' | null = null;
currentKeyword = '';

onInput(event: Event) {
  // 先执行之前的高亮逻辑...
  
  // 检测是否触发联想
  const text = this.editableContainer.nativeElement.innerText;
  const lastTriggerIndex = Math.max(text.lastIndexOf('#'), text.lastIndexOf('@'));
  if (lastTriggerIndex !== -1 && lastTriggerIndex >= this.lastCursorPosition - this.currentKeyword.length -1) {
    this.currentTrigger = text[lastTriggerIndex] as '#' | '@';
    this.currentKeyword = text.slice(lastTriggerIndex + 1, this.lastCursorPosition);
    // 模拟请求数据,实际项目中调用API
    this.suggestions = this.getMockSuggestions(this.currentTrigger, this.currentKeyword);
    this.showSuggestions = true;
    // 定位联想框
    this.positionSuggestionBox();
  } else {
    this.showSuggestions = false;
  }
}

private positionSuggestionBox() {
  const container = this.editableContainer.nativeElement;
  const suggestionBox = this.suggestionList.nativeElement;
  const range = window.getSelection()?.getRangeAt(0);
  if (range) {
    const rect = range.getBoundingClientRect();
    suggestionBox.style.position = 'absolute';
    suggestionBox.style.top = `${rect.bottom + window.scrollY}px`;
    suggestionBox.style.left = `${rect.left + window.scrollX}px`;
  }
}

// 模拟获取联想数据
private getMockSuggestions(trigger: '#' | '@', keyword: string): Array<{name: string, id: string}> {
  const hashtags = [{name: '#Angular', id: '1'}, {name: '#WebDev', id: '2'}, {name: '#Frontend', id: '3'}];
  const mentions = [{name: '@JohnDoe', id: 'a'}, {name: '@JaneSmith', id: 'b'}, {name: '@AngularTeam', id: 'c'}];
  const data = trigger === '#' ? hashtags : mentions;
  return data.filter(item => item.name.toLowerCase().includes(keyword.toLowerCase()));
}

4. 联想项选择与插入

点击联想项时,将选中的内容插入到输入容器中,替换当前正在输入的关键词部分:

selectSuggestion(item: {name: string, id: string}) {
  const container = this.editableContainer.nativeElement;
  const text = container.innerText;
  const lastTriggerIndex = text.lastIndexOf(this.currentTrigger!);
  // 替换关键词为选中的项
  const newText = text.slice(0, lastTriggerIndex) + item.name + text.slice(this.lastCursorPosition);
  // 重新设置内容并恢复光标
  container.innerText = newText;
  this.onInput(new Event('input')); // 触发重新高亮
  this.showSuggestions = false;
  // 将光标移到插入内容的末尾
  this.setCursorPosition(container, lastTriggerIndex + item.name.length);
}

注意事项

  • 处理退格键:当删除#/@内容时,要及时关闭联想列表
  • 兼容性:contenteditable在不同浏览器中的光标处理可能有差异,需要做兼容
  • 性能优化:输入事件触发频繁,可加入防抖处理,避免频繁解析和请求

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.05 05:10:31