PS:点赞,评论,收藏,分享 防止迷路
一 功能描述
使用 contenteditable 的 div文本输入框。
-
用户在可编辑div中输入#号时触发关键词联想
-
联想列表根据输入内容过滤显示
-
支持鼠标点击和键盘导航选择
-
插入的标签不可编辑且带有特殊样式
-
光标定位在标签之后
二 效果展示
三 代码
ini
<!DOCTYPE html>
<html>
<head>
<style>
.editor {
width: 500px;
height: 200px;
border: 1px solid #ccc;
padding: 10px;
font-family: Arial;
position: relative;
outline: none;
}
.suggestions {
position: absolute;
background: white;
border: 1px solid #ddd;
max-height: 200px;
overflow-y: auto;
display: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
.suggestion-item {
padding: 8px;
cursor: pointer;
transition: background 0.2s;
}
.suggestion-item:hover,
.suggestion-item.selected {
background: #f0f0f0;
}
.editor b {
font-weight: bold;
color: #007bff;
display: inline-block;
}
</style>
</head>
<body>
<div id="editor" class="editor" contenteditable="true"></div>
<div id="suggestions" class="suggestions"></div>
<script>
const keywords = [
"前端开发",
"JavaScript",
"CSS技巧",
"Vue框架",
"React教程",
"Node.js",
"Webpack配置",
];
let currentSelection = -1;
let lastWord = "";
let lastValidRange = null;
const editor = document.getElementById("editor");
const suggestions = document.getElementById("suggestions");
// 输入事件监听
editor.addEventListener("input", handleInput);
editor.addEventListener("keydown", handleKeyDown);
// 点击建议项处理(事件委托)
suggestions.addEventListener("mousedown", (e) => {
e.preventDefault(); // 阻止默认焦点变化
const item = e.target.closest(".suggestion-item");
if (item) {
insertSuggestion(item.dataset.value);
}
});
// 输入处理
function handleInput() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (!editor.contains(range.commonAncestorContainer)) return;
// 保存有效选区
lastValidRange = range.cloneRange();
const text = range.startContainer.textContent || "";
const pos = range.startOffset;
// 匹配#号开头的内容
const match = text.slice(0, pos).match(/#([^#\s]*)$/);
if (match) {
lastWord = match[1];
showSuggestions(match[1]);
positionSuggestions(range);
} else {
hideSuggestions();
}
}
// 显示建议列表
function showSuggestions(input) {
const filtered = keywords.filter((k) =>
k.toLowerCase().includes(input.toLowerCase())
);
suggestions.innerHTML = filtered
.map(
(k, i) => `
<div class="suggestion-item ${i === 0 ? "selected" : ""}"
data-value="${k}">
${k.replace(
new RegExp(`(${input})`, "gi"),
"<b>$1</b>"
)}
</div>
`
)
.join("");
currentSelection = filtered.length > 0 ? 0 : -1;
suggestions.style.display = filtered.length ? "block" : "none";
}
// 定位建议框
function positionSuggestions(range) {
const rect = range.getBoundingClientRect();
suggestions.style.top = `${rect.top + window.scrollY + 24}px`;
suggestions.style.left = `${rect.left + window.scrollX}px`;
}
// 插入建议词
function insertSuggestion(text) {
// 恢复焦点和选区
editor.focus();
const selection = window.getSelection();
if (lastValidRange) {
selection.removeAllRanges();
selection.addRange(lastValidRange);
}
requestAnimationFrame(() => {
const range = selection.getRangeAt(0);
const node = range.startContainer;
const offset = range.startOffset;
// 计算替换范围
const fullText = node.textContent || "";
const startPos = fullText.lastIndexOf("#", offset - 1);
if (startPos === -1) return;
// 创建不可编辑标签
const tag = document.createElement("b");
tag.contentEditable = "false";
tag.textContent = `#${text}`;
// 创建零宽空格用于光标定位
const space = document.createTextNode("\u200B");
// 分割原始节点
const beforeText = fullText.slice(0, startPos);
const afterText = fullText.slice(offset);
// 构建新DOM结构
const newNodes = [
document.createTextNode(beforeText),
tag,
document.createTextNode(afterText),
space,
];
// 替换DOM
if (node.nodeType === Node.TEXT_NODE) {
const parent = node.parentNode;
newNodes.forEach((n) => parent.insertBefore(n, node));
parent.removeChild(node);
}
// 定位到零宽空格位置
const newRange = document.createRange();
newRange.setStart(space, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
// 自动滚动到可见区域
tag.scrollIntoView({ block: "nearest" });
hideSuggestions();
});
}
// 键盘处理
function handleKeyDown(e) {
if (suggestions.style.display === "none") return;
const items = suggestions.children;
switch (e.key) {
case "ArrowUp":
e.preventDefault();
updateSelection(currentSelection - 1);
break;
case "ArrowDown":
e.preventDefault();
updateSelection(currentSelection + 1);
break;
case "Enter":
case " ":
e.preventDefault();
if (currentSelection > -1) {
insertSuggestion(items[currentSelection].dataset.value);
}
break;
}
}
// 更新选中项
function updateSelection(newIndex) {
const items = suggestions.children;
if (!items.length) return;
newIndex = Math.max(0, Math.min(items.length - 1, newIndex));
items[currentSelection]?.classList.remove("selected");
currentSelection = newIndex;
items[currentSelection].classList.add("selected");
// 滚动到可见区域
items[currentSelection].scrollIntoView({
block: "nearest",
});
}
function hideSuggestions() {
suggestions.style.display = "none";
currentSelection = -1;
}
// 点击外部隐藏建议框
document.addEventListener("click", (e) => {
if (!editor.contains(e.target) && !suggestions.contains(e.target)) {
hideSuggestions();
}
});
// 新增:处理退格键删除标签
editor.addEventListener("keydown", (e) => {
if (e.key === "Backspace") {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
// 如果光标在标签后,向前跳过一个不可编辑节点
if (
range.startContainer ===
document.querySelector('b[contenteditable="false"] + text')
) {
const prev = range.startContainer.previousSibling;
if (prev?.contentEditable === "false") {
const newRange = document.createRange();
newRange.setStart(
prev.previousSibling,
prev.previousSibling.length
);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
e.preventDefault();
}
}
}
});
</script>
</body>
</html>
四 关键技术讲解
- 要实现插入联想词 不能用 常规的 input 标签,必须要使用 contenteditable 。
contenteditable
是一个枚举属性,表示元素是否可被用户编辑 - window.getSelection 返回 返回一个
Selection
对象,表示用户当前选择的文本范围或光标的当前位置。
-
锚指的是一个选区的起始点(不同于 HTML 中的锚点链接,译者注)。当我们使用鼠标框选一个区域的时候,锚点就是我们鼠标按下瞬间的那个点。在用户拖动鼠标时,锚点是不会变的。
-
选区的焦点是该选区的终点,当你用鼠标框选一个选区的时候,焦点是你的鼠标松开瞬间所记录的那个点。随着用户拖动鼠标,焦点的位置会随着改变。
-
范围指的是文档中连续的一部分。一个范围包括整个节点,也可以包含节点的一部分,例如文本节点的一部分。用户通常下只能选择一个范围,但是有的时候用户也有可能选择多个范围(例如当用户按下 Control 按键并框选多个区域时,Chrome 中禁止了这个操作,译者注)。"范围"会被作为
Range
对象返回。Range 对象也能通过 DOM 创建、增加、删减
- 插入的节点需要设置不可编辑
PS:创作不易 学会了记得,点赞,评论,收藏,分享