原生API编写富文本编辑器004

遗留的问题:

  1. 设置的字体是使用 font属性,而非CSS
  2. 设置的字号只接受1-7, 并且是以 size 属性而非 CSS控制,超出大小无法设置。
  3. color使用HTML的input时,始终有一个input框在那里,并且如果手动触发click显示调色板,则调色板的位置无法自动跟随
  4. link 只能创建或取消,无法修改,无法指定是以何种方式打开
  5. link和image填写框聚焦时编辑器选区会被取消

设置字体字号使用的是HTML属性与标签,而非CSS

富文本编辑器开发系列12——原生API编写简单富文本编辑器004 - 图1

可以看到,在默认情况下,我们对文本的大多数操作都是使用HTML属性或标签的方式完成样式设置的。

如果想让浏览器使用CSS来设置这些样式,那么在编辑器加载前,执行styleWithCSS 命令,将设置的模式设置为css模式即可:

  1. window.onload= function() {
  2. document.execCommand('styleWithCSS', false, '');
  3. //...

富文本编辑器开发系列12——原生API编写简单富文本编辑器004 - 图2

可以看到,这样浏览器就使用css来设置对应样式了,但又有新的问题,即字号不是我们想的按照像素设置的,而是按照浏览器定义的大小描述来设置的。

link和image填写框聚焦时编辑器选区会被取消

这个问题可以通过两种方式解决:

  1. 我们现在的可编辑区域是一个div,而我们的input框与该div同属一个文档,所以当input获得焦点时,可编辑区域就会失去焦点从而失去选区,所以我们只需要将div换成一个frame,将可编辑区放置到iframe里的文档中,这样就不会抢夺焦点了。
  2. 输入框不使用自己写的input,而是使用浏览器的prompt 框,这样也不会与div抢夺焦点。

我们后面使用第一种方式改造,第二种方式有兴趣的读者朋友可以自行尝试。

  1. // index.html
  2. <iframe id="editorContent" class="editor-content" contenteditable="true" frameborder="0"></iframe>
  1. //index.css
  2. .editor-content {
  3. width: 100%;
  4. height: 500px;
  5. overflow: auto;
  6. padding-top: 20px;
  7. }
  1. // index.js
  2. var editor;
  3. window.onload= function() {
  4. editor = document.getElementById("editorContent").contentWindow;//获取iframe Window 对象
  5. editor.document.designMode = 'On'; //打开设计模式
  6. editor.document.contentEditable = true;// 设置元素为可编辑
  7. editor.document.execCommand('styleWithCSS', false, '');
  8. // 后续文件中所有document.execCommand 改为 editor.document.execCommand, 例如:
  9. const rs = editor.document.execCommand('fontName', true, target.value);

富文本编辑器开发系列12——原生API编写简单富文本编辑器004 - 图3

其它问题

要解决其它问题,则需要引入浏览器的另外两个API:rangeselection;

我们下一节再说。

以上代码可在1.0.5 分支上找到。

代码优化

之前我们为了讲解功能实现的具体逻辑和原理,使用的是过程式编码方式,看着很不优雅,而且有很多冗余,下来我们就一步一步优化一下实现方式。

工具条动态生成

我们现在的工具条所有按钮,都是写死在html中的,每个按钮一个li标签,但是这样,一是按钮越多,代码就越多,二是不方便扩展,每次新增一个功能按钮,都要去改html模板。

我们改为使用js动态生成dom的方式来改写。

  1. // index.js
  2. window.onload= function() {
  3. createEditorBar();
  4. // ...
  5. function createEditorBar() {
  6. let $tpl ='<ul>';
  7. const commandsMap = {
  8. 'undo': {
  9. icon: 'chexiao',
  10. title: '撤销',
  11. },
  12. 'redo': {
  13. icon: 'zhongzuo',
  14. title: '重做',
  15. },
  16. 'copy': {
  17. icon: 'fuzhi',
  18. title: '复制',
  19. },
  20. 'cut': {
  21. icon: 'jianqie',
  22. title: '剪切',
  23. },
  24. 'fontName': {
  25. icon: 'ziti',
  26. title: '字体',
  27. },
  28. 'fontSize': {
  29. icon: 'zihao',
  30. title: '字号',
  31. },
  32. 'bold': {
  33. icon: 'zitijiacu',
  34. title: '加粗',
  35. },
  36. 'italic': {
  37. icon: 'zitixieti',
  38. title: '斜体',
  39. },
  40. 'underline': {
  41. icon: 'zitixiahuaxian',
  42. title: '下划线',
  43. },
  44. 'strikeThrough': {
  45. icon: 'zitishanchuxian',
  46. title: '删除线',
  47. },
  48. 'superscript': {
  49. icon: 'zitishangbiao',
  50. title: '上标',
  51. },
  52. 'subscript': {
  53. icon: 'zitixiabiao',
  54. title: '下标',
  55. },
  56. 'fontColor': {
  57. icon: 'qianjingse',
  58. title: '字体颜色',
  59. },
  60. 'backColor': {
  61. icon: 'zitibeijingse',
  62. title: '字体背景色',
  63. },
  64. 'removeFormat': {
  65. icon: 'qingchugeshi',
  66. title: '清除格式',
  67. },
  68. 'insertOrderedList': {
  69. icon: 'youxuliebiao',
  70. title: '有序列表',
  71. },
  72. 'insertUnorderedList': {
  73. icon: 'wuxuliebiao',
  74. title: '无序列表',
  75. },
  76. 'justifyLeft': {
  77. icon: 'juzuoduiqi',
  78. title: '居左对齐',
  79. },
  80. 'justifyRight': {
  81. icon: 'juyouduiqi',
  82. title: '居右对齐',
  83. },
  84. 'justifyCenter': {
  85. icon: 'juzhongduiqi',
  86. title: '居中对齐',
  87. },
  88. 'justifyFull': {
  89. icon: 'liangduanduiqi',
  90. title: '两端对齐',
  91. },
  92. 'createLink': {
  93. icon: 'charulianjie',
  94. title: '插入链接',
  95. },
  96. 'unlink': {
  97. icon: 'quxiaolianjie',
  98. title: '取消链接',
  99. },
  100. 'indent': {
  101. icon: 'shouhangsuojin',
  102. title: '首行缩进',
  103. },
  104. 'insertImage': {
  105. icon: 'tupian',
  106. title: '插入图片',
  107. },
  108. };
  109. for (key in commandsMap) {
  110. $tpl += `<li><button command="${key}"><i class="iconfont icon-${commandsMap[key].icon}" title="${commandsMap[key].title}"></i></button></li>`;
  111. }
  112. $tpl += '</ul>';
  113. const editorBar = document.getElementById('editorBar');
  114. editorBar.innerHTML = $tpl;
  115. }
  1. // index.html
  2. <div id="editorBar" class="editor-toolbar"></div>

统一的下拉框生成方法

目前的下拉框,我们都是新生成按钮,然后再在编辑器初始化的时候动态生成将按钮替换掉的,而且每一个下拉框都有一个单独的生成方法,代码冗余比较多,我们统一使用相同方法生成下拉框的dom,并且在生成工具条的时候直接渲染。

  1. // index.js
  2. const commandsMap = {
  3. //...
  4. 'fontName': {
  5. icon: 'ziti',
  6. title: '字体',
  7. options: [
  8. {
  9. key: '仿宋',
  10. value: "'仿宋'",
  11. },
  12. {
  13. key: '黑体',
  14. value: "'黑体'",
  15. },
  16. {
  17. key: '楷体',
  18. value: "'楷体'",
  19. },
  20. {
  21. key: '宋体',
  22. value: "'宋体'",
  23. },
  24. {
  25. key: '微软雅黑',
  26. value: "'微软雅黑'",
  27. },
  28. {
  29. key: '新宋体',
  30. value: "'新宋体'",
  31. },
  32. {
  33. key: 'Calibri',
  34. value: "'Calibri'",
  35. },
  36. {
  37. key: 'Consolas',
  38. value: "'Consolas'",
  39. },
  40. {
  41. key: 'Droid Sans',
  42. value: "'Droid Sans'",
  43. },
  44. {
  45. key: 'Microsoft YaHei',
  46. value: "'Microsoft YaHei'",
  47. },
  48. ],
  49. styleName: 'font-family',
  50. },
  51. 'fontSize': {
  52. icon: 'zihao',
  53. title: '字号',
  54. options: [
  55. {
  56. key: '12',
  57. value: '12px',
  58. },
  59. {
  60. key: '13',
  61. value: '13px',
  62. },
  63. {
  64. key: '16',
  65. value: '16px',
  66. },
  67. {
  68. key: '18',
  69. value: '18px',
  70. },
  71. {
  72. key: '24',
  73. value: '24px',
  74. },
  75. {
  76. key: '32',
  77. value: '32px',
  78. },
  79. {
  80. key: '48',
  81. value: '48px',
  82. },
  83. ],
  84. styleName: 'font-size',
  85. },
  86. }
  87. //...
  88. for (key in commandsMap) {
  89. if (commandsMap[key].options) {
  90. let id = key + 'Selector';
  91. let customStyleName = commandsMap[key].styleName;
  92. $tpl += getSelectTpl(id, commandsMap[key].options, customStyleName);
  93. } else {
  94. $tpl += `<li><button command="${key}"><i class="iconfont icon-${commandsMap[key].icon}" title="${commandsMap[key].title}"></i></button></li>`;
  95. }
  96. }
  97. function getSelectTpl(id, options, customStyleName) {
  98. let $tpl= `<li><select id="${id}">`;
  99. for (let i = 0; i < options.length; i++) {
  100. $tpl += `<option value="${options[i].value}" style="${customStyleName}: ${options[i].value}">${options[i].key}</option>`;
  101. }
  102. $tpl += '</select></li>';
  103. return $tpl;
  104. }
  105. const editorBar = document.getElementById('editorBar');
  106. editorBar.innerHTML = $tpl;
  107. addSelectorEventListener('fontName');
  108. addSelectorEventListener('fontSize');
  109. function addSelectorEventListener(key) {
  110. const $el = document.getElementById(key + 'Selector');
  111. $el.addEventListener('change', function(e) {
  112. eval('select' + key.substr(0, 1).toUpperCase() + key.substr(1) + '()');
  113. });
  114. }
  115. function selectFontName() {
  116. const target = document.getElementById('fontNameSelector');
  117. const rs = editor.document.execCommand('fontName', true, target.value);
  118. }
  119. function selectFontSize() {
  120. const valueMap = {
  121. '12px': 1,
  122. '13px': 2,
  123. '16px': 3,
  124. '18px': 4,
  125. '24px': 5,
  126. '32px': 6,
  127. '48px': 7,
  128. };
  129. const target = document.getElementById('fontSizeSelector');
  130. const value = valueMap[target.value];
  131. const rs = editor.document.execCommand('fontSize', true, value);
  132. }

统一的对话框生成方法

目前输入超级链接和网络图片地址都使用了一个简单的对话框,这两部分的代码有很多重复和冗余,需要进行优化。

  1. var dialogFun;
  2. case 'createLink':
  3. showDialog(btn, 'link');
  4. break;
  5. case 'insertImage':
  6. showDialog(btn, 'image');
  7. break;
  8. function showDialog(btn, type) {
  9. const upperType = firstLetterToUppercase(type);
  10. const tpl = getDialogTpl(type);
  11. showDialogTpl(btn, tpl);
  12. const dialog = document.getElementById(type + 'Dialog');
  13. dialog.focus();
  14. const createDialogBtn = document.getElementById('create' + upperType + 'Btn');
  15. dialogFun = createDialog.bind(this, type);
  16. createDialogBtn.addEventListener('click', dialogFun, false);
  17. }
  18. function getDialogTpl(type) {
  19. const upperType = firstLetterToUppercase(type);
  20. const tpl = `
  21. <input type="text" id="${type}Dialog" />
  22. <button id="create${upperType}Btn">确定</button>
  23. `;
  24. return tpl;
  25. }
  26. function showDialogTpl(btn, tpl) {
  27. const $dialog = document.getElementById('editorDialog');
  28. $dialog.innerHTML = tpl;
  29. $dialog.style.top = (btn.offsetTop + btn.offsetHeight + 15) + 'px';
  30. $dialog.style.left = btn.offsetLeft + 'px';
  31. $dialog.style.display = 'block';
  32. }
  33. function createDialog(type) {
  34. const upperType = firstLetterToUppercase(type);
  35. const dialog = document.getElementById(type + 'Dialog');
  36. editor.document.execCommand('create' + upperType, 'false', dialog.value);
  37. const createDialogBtn = document.getElementById('create' + upperType + 'Btn');
  38. createDialogBtn.removeEventListener('click', dialogFun, false);
  39. hideDialog();
  40. }
  41. function firstLetterToUppercase(str) {
  42. return str.substr(0, 1).toUpperCase() + str.substr(1);
  43. }
  44. function hideDialog() {
  45. const $dialog = document.getElementById('editorDialog');
  46. $dialog.innerHTML = '';
  47. $dialog.style.display = 'none';
  48. }

至此,我们完成了基础的代码优化,其实就是提取了一些公共方法,通过参数不同来控制不同的输出。

以上代码可以在 1.0.6 分支上找到

问题

现在又有新的问题了,现在我们的所有方法都是暴露在全局环境下的,甚至还有一些全局变量,如果我们的应用中只有一个编辑器实例还好,但是如果同一个页面有两个编辑器,就会很麻烦。

所以,下一节我们将对代码进行面向对象的改造,让同一个页面可以生成多个不同的编辑器实例,各个实例之间可以互不干扰。