跟着做完,你会得到一个能加粗、能插列表、能安全粘贴、能存取 HTML 的迷你富文本编辑器,并且清楚它什么时候该被换掉。
第一步:把普通 div 变成可编辑区域
这一步是给一个普通 <div> 加上 contenteditable,做完之后你在页面上就能直接打字、删字、选中文字,浏览器会自动帮你处理光标和输入法。
新建一个 index.html,内容如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>迷你富文本</title>
<style>
#editor {
min-height: 160px;
padding: 12px;
border: 1px solid #ccc;
border-radius: 6px;
outline: none; /* 去掉聚焦时的默认蓝框,自己画 */
white-space: pre-wrap; /* 保留换行和连续空格 */
word-break: break-word;
line-height: 1.7;
}
#editor:focus { border-color: #4a90d9; }
</style>
</head>
<body>
<div id="editor" contenteditable="true"></div>
</body>
</html>
双击文件用 Chrome 打开,点一下灰色框,就能直接输入了。
几个必须记住的点:
- 属性名是
contenteditable,值必须是字符串 "true" 或 "false",不是布尔值。
- 用 JS 设置时属性名是
contentEditable(大写 E):document.getElementById('editor').contentEditable = 'true'。
outline: none 一定要自己补一个聚焦样式,否则键盘用户看不到焦点在哪。
white-space: pre-wrap 不加的话,用户连按两次空格只能看到一个空格。
如果只想允许纯文本(禁止粘贴带格式的内容),把属性写成 contenteditable="plaintext-only"。这个值 Chrome、Edge、Safari 都支持,Firefox 从 136 版(2025 年 3 月)开始支持。
注意:contenteditable 是会继承的。你在编辑器里插入一个 <div>,它默认可编辑;但如果给它设了 contenteditable="false",那块区域就变成不可编辑的"原子块",光标只能停在它前后——这是做代码块、卡片、@ 提及的常用手法。
第二步:接一条工具栏,实现加粗、斜体、列表
这一步是加一排按钮,点一下就能改选中的文字,做完之后编辑器就有了最基础的富文本能力。
在编辑器上面加一条工具栏:
<div id="toolbar">
<button data-cmd="bold"><b>B</b></button>
<button data-cmd="italic"><i>I</i></button>
<button data-cmd="insertUnorderedList">• 列表</button>
<button data-cmd="insertOrderedList">1. 列表</button>
<button data-cmd="undo">撤销</button>
</div>
<div id="editor" contenteditable="true"></div>
const editor = document.getElementById('editor');
const toolbar = document.getElementById('toolbar');
// 关键:按钮按下时阻止默认行为,否则焦点跑到按钮上,选区就丢了
toolbar.addEventListener('mousedown', (e) => {
if (e.target.closest('button')) e.preventDefault();
});
toolbar.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
editor.focus();
document.execCommand(btn.dataset.cmd, false, null);
});
// 让回车在编辑器里产生 <p> 而不是 <div> 或 <br>
document.execCommand('defaultParagraphSeparator', false, 'p');
刷新页面,选中一段文字点 B,文字就会加粗。
document.execCommand 在规范里已经被标记为废弃(deprecated),但 Chrome、Edge、Safari、Firefox 到今天仍然完整实现它。它不是长期方案,但是"五分钟能跑起来"的方案。不要把它当作能撑三年的技术选型。
按钮高亮状态要跟着光标走:
document.addEventListener('selectionchange', () => {
toolbar.querySelectorAll('button').forEach((btn) => {
const cmd = btn.dataset.cmd;
if (cmd === 'bold' || cmd === 'italic') {
btn.classList.toggle('active', document.queryCommandState(cmd));
}
});
});
注意:selectionchange 必须监听在 document 上,不能监听在 editor 上。多数浏览器不会在元素上派发这个事件,监听错了会一直不触发。
第三步:读取内容和写回内容
这一步是把编辑器里的内容拿出来存起来,以及把存好的内容塞回去。
// 读:拿到 HTML 字符串
const html = editor.innerHTML;
// 写:回填
editor.innerHTML = savedHtml;
存到后端之前建议统一做一次清洗:把 <div> 全部换成 <p>、去掉 style 属性里的 position、background-image 等危险值、剔除 <script> 和所有 on* 事件属性。
function sanitize(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('script,style,iframe,object').forEach(n => n.remove());
doc.querySelectorAll('*').forEach((el) => {
[...el.attributes].forEach((attr) => {
const name = attr.name.toLowerCase();
if (name.startsWith('on') || name === 'srcdoc') el.removeAttribute(attr.name);
});
});
return doc.body.innerHTML;
}
注意:绝对不要写 editor.innerHTML += '<p>新内容</p>'。这会重建整个 DOM,光标会被弹回开头,输入法状态丢失,浏览器的撤销栈也会被清空。
第四步:处理粘贴,别让外部格式污染编辑器
这一步是拦截 paste 事件,把从 Word、网页、微信复制过来的内容变干净。做完之后,用户怎么粘都不会把乱码样式带进来。
只保留纯文本的写法最省事:
editor.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
用 insertText 而不是直接改 DOM,好处是浏览器的撤销栈(Ctrl+Z)仍然认得这一步。
如果想保留部分格式(比如只要加粗和斜体),就读 text/html 再走白名单:
editor.addEventListener('paste', (e) => {
e.preventDefault();
const html = e.clipboardData.getData('text/html');
const text = e.clipboardData.getData('text/plain');
if (!html) {
document.execCommand('insertText', false, text);
return;
}
const doc = new DOMParser().parseFromString(html, 'text/html');
const allow = new Set(['P','BR','STRONG','B','EM','I','U','UL','OL','LI','A','CODE','PRE']);
const walk = (node) => {
[...node.childNodes].forEach((child) => {
if (child.nodeType === 1) {
if (!allow.has(child.tagName)) {
child.replaceWith(...child.childNodes); // 拆掉标签,保留文字
} else {
[...child.attributes].forEach(a => {
if (!(child.tagName === 'A' && a.name === 'href')) child.removeAttribute(a.name);
});
walk(child);
}
}
});
};
walk(doc.body);
document.execCommand('insertHTML', false, doc.body.innerHTML);
});
注意:e.clipboardData 里的数据只在事件回调同步执行期间可读。