第一次使用@人功能到现在已经有差不多10年了,初次使用是通过微博体验的。@人的功能现在遍布各种应用,只要是涉及社交、办公等场景,就是一个必不可少的功能。最近也在调研 IM 的各种功能的实现方案,所以也稍微地了解了下@人功能的前端实现。




微博的实现比较简单,就是通过正则匹配,最后用空格表示匹配结束,所以实现上是直接使用了textarea标签。但是这个实现必须依赖的一个事情是:用户名必须唯一。微博的用户名就是唯一的,所以正则所匹配到的ID,一般的可以映射到唯一的一个用户上(除非ID不存在)。整体的输出比较宽松,你可以构造任何不存在的ID进行@操作。


Twitter 的实现跟微博类似,也是以@开始,空格结尾做匹配。但是使用的是 contenteditable 这个属性进行富文本操作,相似之处在于 Twitter 的 ID 也是唯一,但是可以通过昵称进行搜索,然后转化成 ID,这一点在体验上好了不少。
一般来说,如果像平常用的 Lark 搜索,我们是不会通过唯一的『工号』去进行搜索,而是通过名字,但是名字会出现重复,所以就不太适合用textarea的方式,而是用contenteditable,把@文本替换成HTML标签特殊化标记。
想要获得用户输入的字符串,然后替换进去,第一步就是需要获得用户所在的光标。要获取光标信息,那就要先了解什么是『选择(Selection) 』和『范围(Range) 』。
Range本质上是一对“边界点”:范围起点和范围终点。
每个点都被表示为一个带有相对于起点的相对偏移(offset)的父 DOM 节点。如果父节点是元素节点,则偏移量是子节点的编号,对于文本节点,则是文本中的位置。
例如:
let range = new Range();
然后使用 range.setStart(node, offset) 和 range.setEnd(node, offset) 来设置选择边界。
假设 HTML 片段是这样的:
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
选择 "Example: <i>italic</i>",它是 <p> 的前两个子节点(文本节点也算在内):

<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script> let range = new Range();
range.setStart(p, 0);
range.setEnd(p, 2);
// 范围的 toString 以文本形式返回其内容(不带标签)
alert(range); // Example: italic
document.getSelection().addRange(range); </script>
range.setStart(p, 0) —— 将起点设置为 <p> 的第 0 个子节点(即文本节点 "Example: ")。
range.setEnd(p, 2) —— 覆盖范围至(但不包括)<p> 的第 2 个子节点(即文本节点 " and ",但由于不包括末节点,所以最后选择的节点是 <i>)。
如果像这样操作:

这也是可以做到的,只需要将起点和终点设置为文本节点中的相对偏移量即可。
我们需要创建一个范围:
从 <p> 的第一个子节点的位置 2 开始(选择 "Example: " 中除前两个字母外的所有字母)
到 <b> 的第一个子节点的位置 3 结束(选择 “bold” 的前三个字母,就这些):
<p id="p">Example: <i>italic</i> and <b>bold</b></p>
<script> let range = new Range();
range.setStart(p.firstChild, 2);
range.setEnd(p.querySelector('b').firstChild, 3);
alert(range); // ample: italic and bol
window.getSelection().addRange(range); </script>
range 对象具有以下属性:

startContainer,startOffset —— 起始节点和偏移量,
在上例中:分别是 <p> 中的第一个文本节点和 2。
endContainer,endOffset —— 结束节点和偏移量,
在上例中:分别是 <b> 中的第一个文本节点和 3。
collapsed —— 布尔值,如果范围在同一点上开始和结束(所以范围内没有内容)则为 true,
在上例中:false
commonAncestorContainer —— 在范围内的所有节点中最近的共同祖先节点,
在上例中:<p>
Range 是用于管理选择范围的通用对象。
文档选择是由 Selection 对象表示的,可通过 window.getSelection() 或 document.getSelection() 来获取。
根据 Selection API 规范[1] 一个选择可以包括零个或多个范围。不过实际上,只有 Firefox 允许使用 Ctrl+click (Mac 上用 Cmd+click) 在文档中选择多个范围。
这是在 Firefox 中做的一个具有 3 个范围的选择的截图:

其他浏览器最多支持 1 个范围。正如我们将看到的,某些 Selection 方法暗示可能有多个范围,但同样,在除 Firefox 之外的所有浏览器中,范围最多是 1。
与范围相似,选择的起点称为“锚点(anchor)”,终点称为“焦点(focus)”。
主要的选择属性有:
anchorNode —— 选择的起始节点,
anchorOffset —— 选择开始的 anchorNode 中的偏移量,
focusNode —— 选择的结束节点,
focusOffset —— 选择开始处 focusNode 的偏移量,
isCollapsed —— 如果未选择任何内容(空范围)或不存在,则为 true 。
rangeCount —— 选择中的范围数,除 Firefox 外,其他浏览器最多为 1。
看完上面,不知道了解了没?没关系,我们继续往下。综上所述,一般我们只有一个 Range,当我们的光标在 contenteditable 的 div 上闪动的时候,其实就有了一个 Range,这个 Range 的开始和结束位置都是一样的。另外,我们还可以直接通过 Selection.focusNode获取到对应的节点,通过 Selection.focusOffset 获取到对应的偏移量。就像下图:

这样,我们就获取到了光标的位置以及对应的TextNode对象。
从步骤一我们获得了光标在对应Node节点的偏移量,以及对应的Node节点。那么就可以通过textContent方法获取整个文本。
一般来说,通过一个简单的正则就可以获取@的内容了:
// 获取光标位置
const getCursorIndex = () => {
const selection = window.getSelection();
return selection?.focusOffset;
};
// 获取节点
const getRangeNode = () => {
const selection = window.getSelection();
return selection?.focusNode;
};
// 获取 @ 用户
const getAtUser = () => {
const content = getRangeNode()?.textContent || "";
const regx = /@([^@\s]*)$/;
const match = regx.exec(content.slice(0, getCursorIndex()));
if (match && match.length === 2) {
return match[1];
}
return undefined;
};
因为@的插入可能是末尾,可能是中间,所以我们在判断前,还需要截取光标前的文本。

所以简单地slice一下就好了:
content.slice(0, getCursorIndex())
弹窗是否展示的逻辑,跟判断@用户类似,都是同一个正则。
// 是否展示 @
const showAt = () => {
const node = getRangeNode();
if (!node || node.nodeType !== Node.TEXT_NODE) return false;
const content = node.textContent || "";
const regx = /@([^@\s]*)$/;
const match = regx.exec(content.slice(0, getCursorIndex()));
return match && match.length === 2;
};
弹窗需要出现在正确的位置,幸好现代浏览器有不少好用的API。
const getRangeRect = () => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0)!;
const rect = range.getClientRects()[0];
const LINE_HEIGHT = 30;
return {
x: rect.x,
y: rect.y + LINE_HEIGHT
};
};

当出现弹窗之后,我们还需要拦截掉输入框的『上』、『下』、『回车』的操作,否则在输入框响应这些按键会让光标位置偏移到其他地方。
const handleKeyDown = (e: any) => {
if (showDialog) {
if (
e.code === "ArrowUp" ||
e.code === "ArrowDown" ||
e.code === "Enter"
) {
e.preventDefault();
}
}
};
然后在弹窗里面监听这些按键,实现上下选择、回车确定、关闭弹窗的功能。
const keyDownHandler = (e: any) => {
if (visibleRef.current) {
if (e.code === "Escape") {
props.onHide();
return;
}
if (e.code === "ArrowDown") {
setIndex((oldIndex) => {
return Math.min(oldIndex + 1, (usersRef.current?.length || 0) - 1);
});
return;
}
if (e.code === "ArrowUp") {
setIndex((oldIndex) => Math.max(0, oldIndex - 1));
return;
}
if (e.code === "Enter") {
if (
indexRef.current !== undefined &&
usersRef.current?.[indexRef.current]
) {
props.onPickUser(usersRef.current?.[indexRef.current]);
setIndex(-1);
}
return;
}
}
};

假如文本是:『请帮我泡一杯咖啡@ABC,这是后面的内容』
那么我们需要根据光标的位置,替换掉@ABC文本,然后分成前后两块:『请帮我泡一杯咖啡』、『这是后面的内容』。
为了能实现删除键能把删除全部删除,需要把 at 标签的内容包裹起来。这是第一版写的一个标签,但是如果直接用会有点小问题,留着后续再讨论。
const createAtButton = (user: User) => {
const btn = document.createElement("span");
btn.style.display = "inline-block";
btn.dataset.user = JSON.stringify(user);
btn.className = "at-button";
btn.contentEditable = "false";
btn.textContent = `@${user.name}`;
return btn;
};
首先,我们可以获取 focusNode 节点,然后就可以获取它的父节点以及兄弟节点。现在需要做的是,把旧的文本节点删除,然后在原来的位置上依次插入『请帮我泡一杯咖啡』、【@ABC】、『这是后面的内容』。
parentNode.removeChild(oldTextNode);
// 插在文本框中
if (nextNode) {
parentNode.insertBefore(previousTextNode, nextNode);
parentNode.insertBefore(atButton, nextNode);
parentNode.insertBefore(nextTextNode, nextNode);
} else {
parentNode.appendChild(previousTextNode);
parentNode.appendChild(atButton);
parentNode.appendChild(nextTextNode);
}
我们这一顿操作之前,因为原来的文本节点丢失,所以我们的光标也失去了。这时候就需要重新把光标定位到 at 标签之后。简单来说就是把光标定位到 nextTextNode 节点之前即可。
// 创建一个 Range,并调整光标
const range = new Range();
range.setStart(nextTextNode, 0);
range.setEnd(nextTextNode, 0);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
第2步中,我们创建了 at 标签,但是会有点小问题。

这时候光标就定位到了『按钮边框内』,但光标的位置实际上是正确的。
为了优化这个问题,首先想到的是在nextTextNode中添加一个『0宽字符』:\u200b
// 添加 0 宽字符
const nextTextNode = new Text("\u200b" + restSlice);
// 定位光标时,移动一位
const range = new Range();
range.setStart(nextTextNode, 1);
range.setEnd(nextTextNode, 1);

但是,事情没那么简单。因为我发现如果往前可能也会这样……

最后一想:把内容区弄宽一点不就行了?比如左右加个空格?然后就把标签包裹了一层……
const createAtButton = (user: User) => {
const btn = document.createElement("span");
btn.style.display = "inline-block";
btn.dataset.user = JSON.stringify(user);
btn.className = "at-button";
btn.contentEditable = "false";
btn.textContent = `@${user.name}`;
const wrapper = document.createElement("span");
wrapper.style.display = "inline-block";
wrapper.contentEditable = "false";
const spaceElem = document.createElement("span");
spaceElem.style.whiteSpace = "pre";
spaceElem.textContent = "\u200b";
spaceElem.contentEditable = "false";
const clonedSpaceElem = spaceElem.cloneNode(true);
wrapper.appendChild(spaceElem);
wrapper.appendChild(btn);
wrapper.appendChild(clonedSpaceElem);
return wrapper;
};
穷人粗糙版 at 人,最终完结~

前端富文本的坑确实比较多,之前没怎么了解过这部分的知识。
虽然整个过程很粗糙,但是道理是这么个道理。
如果有兴趣,也可以到 Playground 玩一玩。
不完善的地方很多,有更好的方式可以共同讨论下。
https://codesandbox.io/s/gallant-euclid-4bxsi?file=/src/Editor.tsx:1247-1985
现代 JavaScript 教程[2]
MDN[3]
[1]
Selection API 规范: https://www.w3.org/TR/selection-api/ [2]
现代 JavaScript 教程: https://zh.javascript.info/selection-range [3]
MDN: https://developer.mozilla.org/en-US/docs/Web/API/Range/getClientRects
我正在学习如何使用Nokogiri,根据这段代码我遇到了一些问题:require'rubygems'require'mechanize'post_agent=WWW::Mechanize.newpost_page=post_agent.get('http://www.vbulletin.org/forum/showthread.php?t=230708')puts"\nabsolutepathwithtbodygivesnil"putspost_page.parser.xpath('/html/body/div/div/div/div/div/table/tbody/tr/td/div
总的来说,我对ruby还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用
关闭。这个问题是opinion-based.它目前不接受答案。想要改进这个问题?更新问题,以便editingthispost可以用事实和引用来回答它.关闭4年前。Improvethisquestion我想在固定时间创建一系列低音和高音调的哔哔声。例如:在150毫秒时发出高音调的蜂鸣声在151毫秒时发出低音调的蜂鸣声200毫秒时发出低音调的蜂鸣声250毫秒的高音调蜂鸣声有没有办法在Ruby或Python中做到这一点?我真的不在乎输出编码是什么(.wav、.mp3、.ogg等等),但我确实想创建一个输出文件。
给定这段代码defcreate@upgrades=User.update_all(["role=?","upgraded"],:id=>params[:upgrade])redirect_toadmin_upgrades_path,:notice=>"Successfullyupgradeduser."end我如何在该操作中实际验证它们是否已保存或未重定向到适当的页面和消息? 最佳答案 在Rails3中,update_all不返回任何有意义的信息,除了已更新的记录数(这可能取决于您的DBMS是否返回该信息)。http://ar.ru
我在我的项目目录中完成了compasscreate.和compassinitrails。几个问题:我已将我的.sass文件放在public/stylesheets中。这是放置它们的正确位置吗?当我运行compasswatch时,它不会自动编译这些.sass文件。我必须手动指定文件:compasswatchpublic/stylesheets/myfile.sass等。如何让它自动运行?文件ie.css、print.css和screen.css已放在stylesheets/compiled。如何在编译后不让它们重新出现的情况下删除它们?我自己编译的.sass文件编译成compiled/t
我正在寻找执行以下操作的正确语法(在Perl、Shell或Ruby中):#variabletoaccessthedatalinesappendedasafileEND_OF_SCRIPT_MARKERrawdatastartshereanditcontinues. 最佳答案 Perl用__DATA__做这个:#!/usr/bin/perlusestrict;usewarnings;while(){print;}__DATA__Texttoprintgoeshere 关于ruby-如何将脚
Rackup通过Rack的默认处理程序成功运行任何Rack应用程序。例如:classRackAppdefcall(environment)['200',{'Content-Type'=>'text/html'},["Helloworld"]]endendrunRackApp.new但是当最后一行更改为使用Rack的内置CGI处理程序时,rackup给出“NoMethodErrorat/undefinedmethod`call'fornil:NilClass”:Rack::Handler::CGI.runRackApp.newRack的其他内置处理程序也提出了同样的反对意见。例如Rack
在选择我想要运行操作的频率时,唯一的选项是“每天”、“每小时”和“每10分钟”。谢谢!我想为我的Rails3.1应用程序运行调度程序。 最佳答案 这不是一个优雅的解决方案,但您可以安排它每天运行,并在实际开始工作之前检查日期是否为当月的第一天。 关于ruby-如何每月在Heroku运行一次Scheduler插件?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/8692687/
我有一个对象has_many应呈现为xml的子对象。这不是问题。我的问题是我创建了一个Hash包含此数据,就像解析器需要它一样。但是rails自动将整个文件包含在.........我需要摆脱type="array"和我该如何处理?我没有在文档中找到任何内容。 最佳答案 我遇到了同样的问题;这是我的XML:我在用这个:entries.to_xml将散列数据转换为XML,但这会将条目的数据包装到中所以我修改了:entries.to_xml(root:"Contacts")但这仍然将转换后的XML包装在“联系人”中,将我的XML代码修改为
我有一大串格式化数据(例如JSON),我想使用Psychinruby同时保留格式转储到YAML。基本上,我希望JSON使用literalstyle出现在YAML中:---json:|{"page":1,"results":["item","another"],"total_pages":0}但是,当我使用YAML.dump时,它不使用文字样式。我得到这样的东西:---json:!"{\n\"page\":1,\n\"results\":[\n\"item\",\"another\"\n],\n\"total_pages\":0\n}\n"我如何告诉Psych以想要的样式转储标量?解