原生API编写富文本编辑器004
遗留的问题:
- 设置的字体是使用 font属性,而非CSS
 - 设置的字号只接受1-7, 并且是以 size 属性而非 CSS控制,超出大小无法设置。
 - color使用HTML的input时,始终有一个input框在那里,并且如果手动触发click显示调色板,则调色板的位置无法自动跟随
 - link 只能创建或取消,无法修改,无法指定是以何种方式打开
 - link和image填写框聚焦时编辑器选区会被取消
 
设置字体字号使用的是HTML属性与标签,而非CSS
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z9SjtAUo-1670513126376)(https://gitee.com/hjb2722404/tuchuang/raw/master/img/202205131611343.png)]
可以看到,在默认情况下,我们对文本的大多数操作都是使用HTML属性或标签的方式完成样式设置的。
如果想让浏览器使用CSS来设置这些样式,那么在编辑器加载前,执行styleWithCSS 命令,将设置的模式设置为css模式即可:
window.onload= function() {
 document.execCommand('styleWithCSS', false, '');
	//...
 

可以看到,这样浏览器就使用css来设置对应样式了,但又有新的问题,即字号不是我们想的按照像素设置的,而是按照浏览器定义的大小描述来设置的。
link和image填写框聚焦时编辑器选区会被取消
这个问题可以通过两种方式解决:
- 我们现在的可编辑区域是一个div,而我们的input框与该div同属一个文档,所以当input获得焦点时,可编辑区域就会失去焦点从而失去选区,所以我们只需要将div换成一个
frame,将可编辑区放置到iframe里的文档中,这样就不会抢夺焦点了。 - 输入框不使用自己写的input,而是使用浏览器的prompt 框,这样也不会与div抢夺焦点。
 
我们后面使用第一种方式改造,第二种方式有兴趣的读者朋友可以自行尝试。
// index.html
<iframe id="editorContent" class="editor-content" contenteditable="true" frameborder="0"></iframe>
 
//index.css
.editor-content {
 width: 100%;
 height: 500px;
 overflow: auto;
 padding-top: 20px;
}
 
// index.js
var editor;
  
window.onload= function() {
 editor = document.getElementById("editorContent").contentWindow;//获取iframe Window 对象
 editor.document.designMode = 'On'; //打开设计模式
 editor.document.contentEditable = true;// 设置元素为可编辑
 editor.document.execCommand('styleWithCSS', false, '');
	
// 后续文件中所有document.execCommand 改为 editor.document.execCommand, 例如:
	
	const rs = editor.document.execCommand('fontName', true, target.value);
 

其它问题
要解决其它问题,则需要引入浏览器的另外两个API:range 与 selection;
我们下一节再说。
本系列文章代码可从gitee获取
以上代码可在1.0.5 分支上找到。
代码优化
之前我们为了讲解功能实现的具体逻辑和原理,使用的是过程式编码方式,看着很不优雅,而且有很多冗余,下来我们就一步一步优化一下实现方式。
工具条动态生成
我们现在的工具条所有按钮,都是写死在html中的,每个按钮一个li标签,但是这样,一是按钮越多,代码就越多,二是不方便扩展,每次新增一个功能按钮,都要去改html模板。
我们改为使用js动态生成dom的方式来改写。
// index.js
window.onload= function() {
 createEditorBar();
// ...
	
	function createEditorBar() {
 let $tpl ='<ul>';
 const commandsMap = {
 'undo': {
 icon: 'chexiao',
 title: '撤销',
 },
 'redo': {
 icon: 'zhongzuo',
 title: '重做',
 },
 'copy': {
 icon: 'fuzhi',
 title: '复制',
 },
 'cut': {
 icon: 'jianqie',
 title: '剪切',
 },
 'fontName': {
 icon: 'ziti',
 title: '字体',
 },
 'fontSize': {
 icon: 'zihao',
 title: '字号',
 },
 'bold': {
 icon: 'zitijiacu',
 title: '加粗',
 },
 'italic': {
 icon: 'zitixieti',
 title: '斜体',
 },
 'underline': {
 icon: 'zitixiahuaxian',
 title: '下划线',
 },
 'strikeThrough': {
 icon: 'zitishanchuxian',
 title: '删除线',
 },
 'superscript': {
 icon: 'zitishangbiao',
 title: '上标',
 },
 'subscript': {
 icon: 'zitixiabiao',
 title: '下标',
 },
 'fontColor': {
 icon: 'qianjingse',
 title: '字体颜色',
 },
 'backColor': {
 icon: 'zitibeijingse',
 title: '字体背景色',
 },
 'removeFormat': {
 icon: 'qingchugeshi',
 title: '清除格式',
 },
 'insertOrderedList': {
 icon: 'youxuliebiao',
 title: '有序列表',
 },
 'insertUnorderedList': {
 icon: 'wuxuliebiao',
 title: '无序列表',
 },
 'justifyLeft': {
 icon: 'juzuoduiqi',
 title: '居左对齐',
 },
 'justifyRight': {
 icon: 'juyouduiqi',
 title: '居右对齐',
 },
 'justifyCenter': {
 icon: 'juzhongduiqi',
 title: '居中对齐',
 },
 'justifyFull': {
 icon: 'liangduanduiqi',
 title: '两端对齐',
 },
 'createLink': {
 icon: 'charulianjie',
 title: '插入链接',
 },
 'unlink': {
 icon: 'quxiaolianjie',
 title: '取消链接',
 },
 'indent': {
 icon: 'shouhangsuojin',
 title: '首行缩进',
 },
 'insertImage': {
 icon: 'tupian',
 title: '插入图片',
 },
 };
 for (key in commandsMap) {
 $tpl += `<li><button command="${key}"><i class="iconfont icon-${commandsMap[key].icon}" title="${commandsMap[key].title}"></i></button></li>`;
 }
 $tpl += '</ul>';
 const editorBar = document.getElementById('editorBar');
 editorBar.innerHTML = $tpl;
}
	
	
 
// index.html
<div id="editorBar" class="editor-toolbar"></div>
 
统一的下拉框生成方法
目前的下拉框,我们都是新生成按钮,然后再在编辑器初始化的时候动态生成将按钮替换掉的,而且每一个下拉框都有一个单独的生成方法,代码冗余比较多,我们统一使用相同方法生成下拉框的dom,并且在生成工具条的时候直接渲染。
// index.js
 const commandsMap = {
 	//...
	 'fontName': {
 icon: 'ziti',
 title: '字体',
 options: [
 {
 key: '仿宋',
 value: "'仿宋'",
 },
 {
 key: '黑体',
 value: "'黑体'",
 },
 {
 key: '楷体',
 value: "'楷体'",
 },
 {
 key: '宋体',
 value: "'宋体'",
 },
 {
 key: '微软雅黑',
 value: "'微软雅黑'",
 },
 {
 key: '新宋体',
 value: "'新宋体'",
 },
 {
 key: 'Calibri',
 value: "'Calibri'",
 },
 {
 key: 'Consolas',
 value: "'Consolas'",
 },
 {
 key: 'Droid Sans',
 value: "'Droid Sans'",
 },
 {
 key: 'Microsoft YaHei',
 value: "'Microsoft YaHei'",
 },
 ],
 styleName: 'font-family',
 },
 'fontSize': {
 icon: 'zihao',
 title: '字号',
 options: [
 {
 key: '12',
 value: '12px',
 },
 {
 key: '13',
 value: '13px',
 },
 {
 key: '16',
 value: '16px',
 },
 {
 key: '18',
 value: '18px',
 },
 {
 key: '24',
 value: '24px',
 },
 {
 key: '32',
 value: '32px',
 },
 {
 key: '48',
 value: '48px',
 },
 ],
 styleName: 'font-size',
 },
 }
 
 //...
 for (key in commandsMap) {
 if (commandsMap[key].options) {
 let id = key + 'Selector';
 let customStyleName = commandsMap[key].styleName;
 $tpl += getSelectTpl(id, commandsMap[key].options, customStyleName);
 } else {
 $tpl += `<li><button command="${key}"><i class="iconfont icon-${commandsMap[key].icon}" title="${commandsMap[key].title}"></i></button></li>`;
 }
 }
function getSelectTpl(id, options, customStyleName) {
 let $tpl= `<li><select id="${id}">`;
 for (let i = 0; i < options.length; i++) {
 $tpl += `<option value="${options[i].value}" style="${customStyleName}: ${options[i].value}">${options[i].key}</option>`;
 }
 $tpl += '</select></li>';
 return $tpl;
}
const editorBar = document.getElementById('editorBar');
    editorBar.innerHTML = $tpl;
    addSelectorEventListener('fontName');
    addSelectorEventListener('fontSize');
function addSelectorEventListener(key) {
    const $el = document.getElementById(key + 'Selector');
    $el.addEventListener('change', function(e) {
        eval('select' + key.substr(0, 1).toUpperCase() + key.substr(1) + '()');
    });
}
  
function selectFontName() {
    const target = document.getElementById('fontNameSelector');
    const rs = editor.document.execCommand('fontName', true, target.value);
}
  
function selectFontSize() {
    const valueMap = {
        '12px': 1,
        '13px': 2,
        '16px': 3,
        '18px': 4,
        '24px': 5,
        '32px': 6,
        '48px': 7,
    };
    const target = document.getElementById('fontSizeSelector');
    const value = valueMap[target.value];
    const rs = editor.document.execCommand('fontSize', true, value);
}
 
统一的对话框生成方法
目前输入超级链接和网络图片地址都使用了一个简单的对话框,这两部分的代码有很多重复和冗余,需要进行优化。
var dialogFun;
case 'createLink':
	showDialog(btn, 'link');
	break;
case 'insertImage':
	showDialog(btn, 'image');
	break;
function showDialog(btn, type) {
    const upperType = firstLetterToUppercase(type);
    const tpl = getDialogTpl(type);
    showDialogTpl(btn, tpl);
    const dialog = document.getElementById(type + 'Dialog');
    dialog.focus();
    const createDialogBtn = document.getElementById('create' + upperType + 'Btn');
    dialogFun = createDialog.bind(this, type);
    createDialogBtn.addEventListener('click', dialogFun, false);
}
  
function getDialogTpl(type) {
    const upperType = firstLetterToUppercase(type);
    const tpl = `
        <input type="text" id="${type}Dialog" />
        <button id="create${upperType}Btn">确定</button>
    `;
    return tpl;
}
  
function showDialogTpl(btn, tpl) {
    const $dialog = document.getElementById('editorDialog');
    $dialog.innerHTML = tpl;
    $dialog.style.top = (btn.offsetTop + btn.offsetHeight + 15) + 'px';
    $dialog.style.left = btn.offsetLeft + 'px';
    $dialog.style.display = 'block';
}
  
function createDialog(type) {
    const upperType = firstLetterToUppercase(type);
    const dialog = document.getElementById(type + 'Dialog');
    editor.document.execCommand('create' + upperType, 'false', dialog.value);
    const createDialogBtn = document.getElementById('create' + upperType + 'Btn');
    createDialogBtn.removeEventListener('click', dialogFun, false);
    hideDialog();
}
  
function firstLetterToUppercase(str) {
    return str.substr(0, 1).toUpperCase() + str.substr(1);
}
  
function hideDialog() {
    const $dialog = document.getElementById('editorDialog');
    $dialog.innerHTML = '';
    $dialog.style.display = 'none';
}
 
至此,我们完成了基础的代码优化,其实就是提取了一些公共方法,通过参数不同来控制不同的输出。
本系列文章代码可从gitee获取
以上代码可以在 1.0.6 分支上找到
问题
现在又有新的问题了,现在我们的所有方法都是暴露在全局环境下的,甚至还有一些全局变量,如果我们的应用中只有一个编辑器实例还好,但是如果同一个页面有两个编辑器,就会很麻烦。
所以,下一节我们将对代码进行面向对象的改造,让同一个页面可以生成多个不同的编辑器实例,各个实例之间可以互不干扰。


















