简介:layui富文本编辑器layedit.js配套资源包,面向使用Layui框架的前端开发者,用于快速集成并定制字体、字号、字体颜色、背景颜色等核心编辑能力。整包共126个文件,压缩后仅849KB,包含75个gif、29个js、6个css、4个html以及png、woff、ttf等字体资源;js负责编辑器逻辑与工具栏交互,css控制界面样式,gif为表情与图标素材,html示例页面便于对照调用。除文字样式外,编辑器还内置插入表格、批量上传图片、图片尺寸对齐、超链接、视频、全屏、段落格式与锚点跳转等功能,适合内容管理、博客、后台评论等场景。资源包静态文件与示例页面分离,结构清晰,引入后即可获得完整渲染与交互能力,相对自行拼接依赖能明显节省集成与调试时间。当前已有262人学习,适合需要为layui项目快速集成富文本模块的同学下载参考。
1. 别再为 layedit 没有字号和颜色换编辑器了
很多后台管理系统都在用 layui 自带的 layedit 富文本编辑器,图它不用额外引第三方编辑器,样式也贴合 layui 生态。可一旦业务方提出“标题要能调字号、文字要能换颜色、重点句子要加背景底色”,你打开 layedit 的官方文档就会发现,它的 tool 参数里根本没有 fontSize、foreColor 这几个键。换编辑器动静大,改源码又怕以后升级被覆盖。这篇笔记就来解决这个卡点:不改 layedit 源码,在工具栏上扩展出字体大小、字体颜色、背景颜色三个功能。读完你能照着一套步骤走通,也能理解为什么很多人加完按钮点了没反应。
2. 环境准备:挑一个少踩坑的 layui 版本并初始化 layedit
2.1 版本怎么选:2.8.x / 2.9.x 与模块文件位置
layui 从 2.x 中期开始,layedit模块就一直放在lay/modules/layedit.js,这个路径在 2.8.x 和 2.9.x 里没有变过,所以只要你不是还在用上古的 1.x 版本,下面的初始化代码基本都能直接用。我接触过的项目里,存量最多的是 2.6.x、2.8.x,新项目一般直接上 2.9.x,它们对 layedit 的 API 兼容性做得还行。真正要留心的是早期 2.0 时代的写法,那时候有人用tools而不是tool传参,照旧文档抄会直接报错。
下载 layui 时建议拿完整发行包,不要只拷单个 js 文件。原因有二:一是 layedit 依赖 layui 内部的lay基础模块,单独拷贝容易漏文件;二是发行包里的lay/modules/layedit.js和css/layui.css是配套的,工具栏图标的样式在 css 里,只拷 js 不拷 css,编辑器会渲染成一堆没有样式的裸 label。
一个常见的本地目录结构是这样:
项目目录 └─ public └─ libs └─ layui ├─ css │ └─ layui.css ├─ lay │ ├─ all.js │ └─ modules │ └─ layedit.js # 编辑器的核心模块 ├─ layui.js └─ layui.all.js如果你的项目里已经用了layui.all.js,那 layedit 模块已经在里面了,不需要再单独引。但我个人习惯还是用模块化加载:让用户只加载用到的模块,后面排查问题时,也能在 Network 面板里直接看到 layedit.js 是否真的被加载,省去“为什么 layedit 是 undefined”这类黑匣子问题。
2.2 初始化:写一个能跑通的最小 layedit 实例
先用一个最小页面把 layedit 跑起来,确认基础环境没问题,再往上面加扩展。新建一个 HTML 文件,引入 layui.css 和 layui.js:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>layedit 最小初始化</title> <link rel="stylesheet" href="/libs/layui/css/layui.css"> </head> <body> <div style="padding: 30px;"> <textarea id="demo" placeholder="请输入内容"></textarea> </div> <script src="/libs/layui/layui.js"></script> <script> layui.use('layedit', function () { var layedit = layui.layedit; // build 返回编辑器的索引,后面要操作它时都会用到 var index = layedit.build('demo', { tool: ['strong', 'italic', 'underline', 'del', '|', 'left', 'center', 'right', '|', 'link', 'unlink', 'face', 'image'], height: 280 }); // 验证实例是否可用 layedit.sync(index); console.log('layedit index =', index); }); </script> </body> </html>layedit.build接收两个参数:第一个是 textarea 的 id,第二个是配置对象。配置里tool数组决定工具栏显示哪些按钮,|是分隔符,height是编辑区高度,单位 px,默认值我记得是 280。这一步跑通后,页面上原来的 textarea 会被渲染成一个全新的编辑区,外面有工具条,里面是可编辑的 iframe 区域。
tool 数组里官方支持的项是固定的,我列几个高频的:
| tool 参数值 | 作用 |
|---|---|
| strong | 加粗 |
| italic | 斜体 |
| underline | 下划线 |
| del | 删除线 |
| left / center / right | 左对齐 / 居中 / 右对齐 |
| link / unlink | 插入链接 / 取消链接 |
| face | 字体样式下拉 |
| image | 图片上传 |
注意这个表里没有字号和颜色,这就是我们要在第四章自己补的原因。tool 数组只决定官方按钮的开与关,不支持自定义 item 类型。
2.3 确认基础功能正常:三个快速检查点
初始化写完先别急着扩展,把三个检查点过一遍,确保 layedit 本身是好的:
- 页面上原来的 textarea 消失了,替换成一个带工具条的编辑区,说明 build 执行成功。
- 在编辑区里输入文字后,打开浏览器控制台执行
layedit.getText(index),能拿到你刚输入的内容,说明实例可用。 - 点击工具条上的加粗按钮,选中一段文字后能变成粗体,说明 iframe 里的事件绑定正常。
如果卡在第一步,多半是 layui.js 没引对或路径 404;卡在第二步,检查 layedit.js 是否被模块加载器正确拉取,可以在 Network 面板里搜 layedit;卡在第三步,说明 css 样式没渲染出来,工具按钮点击区域不对。这三个检查点过了,后面的扩展才有意义,否则你会在一个本身就坏掉的环境上排查新功能,怎么查都是玄学。
3. 解剖 layedit 编辑区:扩展前先看懂渲染结构
3.1 build 之后编辑器结构变成什么样
很多人在扩展 layedit 时翻车,是因为根本没搞清楚 build 之后页面上发生了什么。它并不是简单地把 textarea 显示出来,而是把 textarea 隐藏,然后在它的位置生成一套新的 DOM 结构。我按照实际渲染结果整理出来的结构大致是:
textarea#demo(display: none) └─ div.layui-layedit ├─ div.layui-layedit-tool │ └─ 一系列 span / i 图标按钮 └─ div.layui-layedit-iframe └─ iframe └─ body(contenteditable=true)也就是说,你的内容不是直接写在 textarea 里的,而是写在一个 iframe 的 body 里。这个设计的好处是:编辑区样式和页面样式完全隔离,用户写的 HTML 不会污染页面全局,页面 css 也不会干扰编辑区渲染。坏处也明显:你想操作编辑区内容,得先拿到 iframe 内部的 document,也就是我们常说的 contentDocument。
记住一个核心概念:对 layedit 的富文本操作,本质上都是拿到 iframe 的 document,然后在那个 document 上执行命令。你在父页面操作 DOM 的方式,在 iframe 里并不直接适用。
3.2 定位工具栏和 iframe:两种拿法对比
操作 iframe 之前,得先稳定地拿到它。layedit 官方提供了一个layedit.get(index)API,不过我在实际项目里不太愿意把扩展功能押在它身上,因为不同版本里它的返回值存在差异,有的版本返回 iframe 的 document,有的版本返回 iframe 对象,文档又没有写得很明确,为了兼容性我得写一堆类型判断,不划算。
我一般直接用 DOM 方式定位,结构稳定且不受内部实现影响:
function getEditIFrame(buildIndex) { // 一个页面可能同时有多个 layedit,按照 build 的先后顺序,dom 顺序也是对应的 var frames = document.querySelectorAll('.layui-layedit iframe'); return frames[buildIndex] || null; } function getEditDoc(buildIndex) { var iframe = getEditIFrame(buildIndex); if (!iframe) { throw new Error('layedit iframe not found, index=' + buildIndex); } return iframe.contentDocument; }这里面的逻辑是:layedit 每 build 一次,就会在.layui-layedit容器里增加一个 iframe,而且 DOM 顺序和 build 调用的顺序一致。所以用 build 返回的 index 去数组中取,是稳定对应的。
两种拿法的对比,我自己使用的判断标准是:
| 拿法 | 优势 | 风险 |
|---|---|---|
| layedit.get(index) | 官方 API,看起来简洁 | 内部返回值随版本波动,需要兼容判断 |
| querySelectorAll('.layui-layedit iframe')[index] | 不依赖内部实现,DOM 结构稳定 | 需要自己保证 index 对应 |
对于做功能扩展来说,稳定比简洁重要。我所有后续跟 iframe 有关的功能,都建立在getEditDoc这个函数之上。
4. 扩展工具栏:给 layedit 加字体大小、字体颜色、背景颜色
4.1 追加自定义控件:select 与按钮的取舍
要在 layedit 工具栏上加东西,最直接的做法是往.layui-layedit-tool容器里追加元素。这个操作不需要改 layedit 源码,也不需要 hack 它的内部对象,就是把普通的 DOM 塞进工具栏容器里,事件自己绑定。
控件形态上,我优先选择<select>下拉框,而不是一排颜色方块按钮。原因有三个:一是字号天然是列表场景,用下拉符合用户习惯;二是颜色数量多,一排按钮占地方,下拉框可以放进几十个预设色值;三是一个下拉框只需要一个 change 事件,调试起来比十个按钮绑十个事件容易得多。
实现一个通用的追加方法:
function appendToolSelect(toolBar, options, changeHandler) { var select = document.createElement('select'); select.className = 'layedit-ext-select'; select.style.cssText = 'height:26px;margin:3px 4px 0 0;border:1px solid #e2e2e2;' + 'border-radius:2px;background:#fff;font-size:12px;'; options.forEach(function (item) { var option = document.createElement('option'); option.value = item.value; option.text = item.text; select.appendChild(option); }); // 关键一步:阻止 mousedown 默认行为,避免下拉框抢走 iframe 的选区 select.addEventListener('mousedown', function (e) { e.preventDefault(); }); select.addEventListener('change', function () { changeHandler(select.value, select); }); toolBar.appendChild(select); return select; }这个函数的核心在mousedown的preventDefault()。富文本编辑器里,用户先在 iframe 中选中一段文字,然后才去点工具栏。如果你用的是普通 select,点击它的时候浏览器会把焦点从 iframe 挪到 select 上,iframe 里的选区随之丢失,等你再执行 execCommand 时已经没有选中内容了,命令自然失效。preventDefault能阻止 select 抢焦点,保住选区。这是扩展 layedit 最容易踩的坑,请务必记住这一行。
4.2 字体大小:execCommand 配合 font 转 span
字号功能不能直接传像素值。浏览器提供的document.execCommand('fontSize', false, value)只接受 1 到 7 的 HTML 字号,不是 px,传 16px 进去会被静默忽略。这个设计很老派,但 layedit 内部也依赖 execCommand 做事,所以扩展功能时还得顺着它的规矩来。
我习惯把 1 到 7 映射成一套固定 px,保证不同系统、不同浏览器下看到的一致:
| size 值 | 浏览器默认渲染 | 常见映射为 |
|---|---|---|
| 1 | 约 10px | 12px |
| 2 | 约 13px | 14px |
| 3 | 16px | 16px |
| 4 | 约 18px | 18px |
| 5 | 约 24px | 24px |
| 6 | 约 32px | 32px |
| 7 | 约 48px | 42px |
映射关系可以按产品需求调整,比如后台系统需要更大的正文就调大 5 到 7 的映射值。用 execCommand 设完字号后,浏览器会往选中文字外层套一个<font size="4">标签,这个标签在现代前端里已经不合时宜了,而且部分浏览器把它渲染得很随意。所以我会在执行命令后立刻把font标签替换成带内联样式的span,这样既能显示正确字号,提交到后端时样式也还在。
var fontSizeMap = { '1': '12px', '2': '14px', '3': '16px', '4': '18px', '5': '24px', '6': '32px', '7': '42px' }; function applyFontSize(doc, sizeVal) { doc.execCommand('fontSize', false, String(sizeVal)); // 把命令生成的 <font size="x"> 替换成 <span style="font-size: xpx"> var fontNodes = doc.querySelectorAll('font[size="' + sizeVal + '"]'); Array.prototype.forEach.call(fontNodes, function (node) { var span = doc.createElement('span'); span.style.fontSize = fontSizeMap[sizeVal]; span.innerHTML = node.innerHTML; node.parentNode.replaceChild(span, node); }); }执行顺序有讲究:先 execCommand,再立即替换。因为 execCommand 是在当前选区上操作的,一旦选区发生变化,这一轮命令就落了空,所以替换操作要在同一轮事件循环里紧跟着做,中间不能再插入别的交互。另外,如果你的程序里需要连续替换多个字号,建议每执行一次命令后重新用querySelectorAll查询一遍,不要缓存 nodeList,因为替换过程中 DOM 是实时变化的。
4.3 字体颜色与背景颜色:foreColor 与 hiliteColor 的兼容写法
字体颜色用foreColor命令,背景高亮用hiliteColor。这两个命令的兼容性总体不错,但有个老 IE 的遗留问题:部分浏览器不认hiliteColor,只认backColor。我一般写一个兼容分支,先试hiliteColor,捕获异常后再回退到backColor。
function applyForeColor(doc, color) { doc.execCommand('foreColor', false, color); } function applyBackColor(doc, color) { try { doc.execCommand('hiliteColor', false, color); } catch (e) { doc.execCommand('backColor', false, color); } }颜色的取值有个容易被忽略的细节:execCommand 对颜色字符串的解析并不统一,最好传#rrggbb格式,例如#ff0000,不要传red,也不要传#f00。部分浏览器能识别简写,部分会直接丢弃命令。色板里的预设值我都是直接写全的六位 hex,下面的 demo 里就是这么处理的。
背景色这里多说一句:hiliteColor生成的样式在不同浏览器里表现略有差异,Chrome 系套的是span加背景色,Firefox 可能用font标签包背景。如果你需要提交到后端的 HTML 格式高度统一,可以在执行命令后做一次 DOM 遍历,把所有带style="background-color"的font标签统一转成span,思路和字号替换完全一致。
4.4 组装:把扩展控件挂到 layedit 工具栏
控件和命令都准备好之后,在 build 完成时把它们挂上去。这里有个重要逻辑:工具栏容器要在 build 执行之后再去查找,因为 build 之前页面上只有裸露的 textarea,.layui-layedit-tool还不存在。
layui.use('layedit', function () { var layedit = layui.layedit; var index = layedit.build('demo', { tool: ['strong', 'italic', 'underline', 'del', '|', 'left', 'center', 'right', '|', 'link', 'unlink', 'face', 'image'], height: 280 }); var doc = getEditDoc(index); var toolBar = document.querySelectorAll('.layui-layedit-tool')[index]; // 字号下拉 appendToolSelect(toolBar, [ { value: '默认', text: '默认字号' }, { value: '1', text: '12px' }, { value: '2', text: '14px' }, { value: '3', text: '16px' }, { value: '4', text: '18px' }, { value: '5', text: '24px' }, { value: '6', text: '32px' }, { value: '7', text: '42px' } ], function (val) { if (val !== '默认') { applyFontSize(doc, val); } }); // 字体颜色下拉 appendToolSelect(toolBar, [ { value: '#333333', text: '默认颜色' }, { value: '#ff0000', text: '红色' }, { value: '#ff8c00', text: '橙色' }, { value: '#008000', text: '绿色' }, { value: '#0000ff', text: '蓝色' } ], function (val) { applyForeColor(doc, val); }); // 背景颜色下拉 appendToolSelect(toolBar, [ { value: '#ffffff', text: '默认背景' }, { value: '#ffff00', text: '黄色高亮' }, { value: '#add8e6', text: '浅蓝高亮' }, { value: '#ffc0cb', text: '粉色高亮' } ], function (val) { applyBackColor(doc, val); }); });注意一个地方:getEditDoc返回的是 iframe 的 document,后续的 execCommand 都作用在这个 doc 上,不是父页面的 document。很多人最后功能没生效,就是命令执行在错误的 document 上——在父页面调document.execCommand('fontSize'),选区在 iframe 里,根本配不上,自然没反应。
5. 避坑指南:layedit 扩展时最容易翻车的几个细节
5.1 下拉框一点,选中文字就没了,功能失效
现象:在 iframe 里选中一段文字,再去点字号下拉框,松手后文字还是老样子,甚至选区消失。
原因:select 元素的 mousedown 默认行为会把焦点从 iframe 抢走,富文本选区随之清空,后续的 execCommand 执行时没有目标选区。
解决:在 select 的 mousedown 事件里调用e.preventDefault(),阻止它获得焦点;执行命令前还要主动调一次iframe.contentWindow.focus(),把焦点还给编辑区。顺序不能反,先阻止抢焦点,才能保住选区。
5.2 字号选了半天,文字纹丝不动
现象:下拉框里选了 16px,编辑器里的文字没有任何变化。
原因:execCommand('fontSize', false, value)接收的是 1 到 7,不是像素值。传 16 进去,命令静默失败,控制台也不报错,特别容易让人误以为是调用时机不对。
解决:把下拉框的 value 设为 1 到 7,再用映射表转换成 px,并执行 font 转 span 的替换逻辑。这也是我为什么反复强调映射表的原因,没有这层转换,字号功能在逻辑上就走不通。
5.3 build 之后立刻取 iframe,contentDocument 是 null
现象:build 完马上调用getEditDoc(index),返回的 doc 是 null,后续绑定事件全部报错。
原因:iframe 元素虽然已经插入 DOM,但内部文档还没加载完成,contentDocument暂时不可用。
解决:把后续扩展代码放到 iframe 的 load 事件里执行,或者放在一个短延时 setTimeout 中。实际项目里 build 的 iframe 加载很快,load 事件不会造成可感知的延迟,比盲等稳得多:
var iframe = getEditIFrame(index); iframe.addEventListener('load', function () { initLayeditExtension(index); });5.4 提交表单后,后端收到的内容为空
现象:编辑器里文字编辑得好好的,提交表单后后台收到的字段是空字符串,或者还是初始内容。
原因:layedit 的内容始终保存在 iframe 里,textarea 的值不会自动跟着变。提交时如果直接读 textarea,读到的自然是空。
解决:提交前必须执行一次layedit.sync(index),把 iframe 内容同步回 textarea。我一般在表单的 submit 事件里统一同步一次,再走表单序列化,不要每个编辑器各自调。
5.5 页面上多个编辑器,扩展按钮全都作用到第一个编辑器上
现象:页面初始化了三个 layedit,点第二个编辑器的颜色按钮,第一个编辑器的文字变了。
原因:扩展代码里的 select 和 iframe 没有正确关联,几个 select 的闭包都引用了同一个 doc 对象。
解决:每个编辑器 build 之后,单独创建一组扩展控件,把各自的 doc 对象通过闭包绑死在 change 回调里。不要写一个共享的全局 doc。代码上建议把每个编辑器的扩展逻辑封装成一个独立函数,传 index 进去。
6. 验证跑通:完整 demo 与三个必测项
6.1 完整 Demo
把前面所有代码整合成一个可复制的页面,方便你直接放到静态服务器里跑。本地起个 nginx,或者任意静态文件服务,访问这个页面就能看到完整效果。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>layedit 字号与颜色扩展 Demo</title> <link rel="stylesheet" href="/libs/layui/css/layui.css"> </head> <body> <div style="padding: 30px;"> <textarea id="demo" placeholder="请输入内容"></textarea> <button id="submitBtn" style="margin-top: 16px;">提交并打印内容</button> <pre id="output" style="margin-top: 12px; background: #f7f7f7; padding: 12px;"></pre> </div> <script src="/libs/layui/layui.js"></script> <script> layui.use('layedit', function () { var layedit = layui.layedit; var fontSizeMap = { '1': '12px', '2': '14px', '3': '16px', '4': '18px', '5': '24px', '6': '32px', '7': '42px' }; function getEditIFrame(buildIndex) { var frames = document.querySelectorAll('.layui-layedit iframe'); return frames[buildIndex] || null; } function getEditDoc(buildIndex) { var iframe = getEditIFrame(buildIndex); if (!iframe) { throw new Error('layedit iframe not found, index=' + buildIndex); } return iframe.contentDocument; } function appendToolSelect(toolBar, options, changeHandler) { var select = document.createElement('select'); select.style.cssText = 'height:26px;margin:3px 4px 0 0;border:1px solid #e2e2e2;' + 'border-radius:2px;background:#fff;font-size:12px;'; options.forEach(function (item) { var option = document.createElement('option'); option.value = item.value; option.text = item.text; select.appendChild(option); }); select.addEventListener('mousedown', function (e) { e.preventDefault(); }); select.addEventListener('change', function () { changeHandler(select.value); }); toolBar.appendChild(select); } function applyFontSize(doc, sizeVal) { doc.execCommand('fontSize', false, String(sizeVal)); var fontNodes = doc.querySelectorAll('font[size="' + sizeVal + '"]'); Array.prototype.forEach.call(fontNodes, function (node) { var span = doc.createElement('span'); span.style.fontSize = fontSizeMap[sizeVal]; span.innerHTML = node.innerHTML; node.parentNode.replaceChild(span, node); }); } function applyForeColor(doc, color) { doc.execCommand('foreColor', false, color); } function applyBackColor(doc, color) { try { doc.execCommand('hiliteColor', false, color); } catch (e) { doc.execCommand('backColor', false, color); } } var index = layedit.build('demo', { tool: ['strong', 'italic', 'underline', 'del', '|', 'left', 'center', 'right', '|', 'link', 'unlink', 'face', 'image'], height: 280 }); var iframe = getEditIFrame(index); iframe.addEventListener('load', function () { var doc = getEditDoc(index); var toolBar = document.querySelectorAll('.layui-layedit-tool')[index]; appendToolSelect(toolBar, [ { value: '', text: '默认字号' }, { value: '1', text: '12px' }, { value: '2', text: '14px' }, { value: '3', text: '16px' }, { value: '4', text: '18px' }, { value: '5', text: '24px' }, { value: '6', text: '32px' }, { value: '7', text: '42px' } ], function (val) { if (val) { applyFontSize(doc, val); } }); appendToolSelect(toolBar, [ { value: '#333333', text: '默认颜色' }, { value: '#ff0000', text: '红色' }, { value: '#ff8c00', text: '橙色' }, { value: '#008000', text: '绿色' }, { value: '#0000ff', text: '蓝色' } ], function (val) { applyForeColor(doc, val); }); appendToolSelect(toolBar, [ { value: '#ffffff', text: '默认背景' }, { value: '#ffff00', text: '黄色高亮' }, { value: '#add8e6', text: '浅蓝高亮' }, { value: '#ffc0cb', text: '粉色高亮' } ], function (val) { applyBackColor(doc, val); }); }); document.getElementById('submitBtn').addEventListener('click', function () { layedit.sync(index); var html = document.getElementById('demo').value; document.getElementById('output').textContent = html; }); }); </script> </body> </html>这一版把前面的所有函数都收了进来。load 事件里做扩展,保证了 iframe 文档一定可用;三个下拉框各管各的,闭包都绑定了自己的 doc;提交按钮先 sync 再读 textarea,打印出最终 HTML。
6.2 三个必测项
落地之后,我劝你不要只看“能打字”就觉得完事了。至少按下面这张表过一遍:
| 测试项 | 操作步骤 | 期望结果 |
|---|---|---|
| 字号 | 输入一段文字,选中它,选 16px | 文字变大,查看 HTML 源码中有span style="font-size: 16px" |
| 字体颜色 | 输入一段文字,选中它,选红色 | 文字变红,源码中出现#ff0000颜色标记 |
| 背景颜色 | 输入一段文字,选中它,选黄色高亮 | 文字出现黄色背景,源码中能找到背景色标记 |
另外,一定要测一次表单提交:在编辑器里排一段带字号、两种颜色的内容,点提交按钮,看打印出的 HTML 里是否保留了内联样式。这一步同时验证了 sync 有没有漏掉。如果打印出来的内容非空且带style属性,说明后端也能收到完整内容。
我没用自动化组件去干这件事,手动过一次也就两分钟。但从那以后我每次给后台管理系统接 layedit 富文本,都会强制把三件事走一遍:扩展按钮是否触发、execCommand 是否作用在 iframe、提交前是否 sync。这三件事同时过了,业务方再提字号颜色需求,我基本不用改第二遍。希望帮到你。
本文还有配套的精品资源,点击获取