UNPKG

mdui.editor

Version:

Material Design 样式的富文本编辑器

378 lines (377 loc) 11.9 kB
import $ from 'mdui.jq/es/$'; import extend from 'mdui.jq/es/functions/extend'; import 'mdui.jq/es/methods/addClass'; import 'mdui.jq/es/methods/append'; import 'mdui.jq/es/methods/attr'; import 'mdui.jq/es/methods/children'; import 'mdui.jq/es/methods/first'; import 'mdui.jq/es/methods/html'; import 'mdui.jq/es/methods/is'; import 'mdui.jq/es/methods/last'; import 'mdui.jq/es/methods/off'; import 'mdui.jq/es/methods/on'; import 'mdui.jq/es/methods/removeClass'; import 'mdui.jq/es/methods/text'; import Command from './command/index'; import Menus from './menus/index'; import Selection from './selection/index'; import { getPasteText } from './utils/paste'; import { purifier } from './utils/purifier'; const DEFAULT_OPTIONS = { // eslint-disable-next-line @typescript-eslint/no-empty-function onchange: () => { }, // eslint-disable-next-line @typescript-eslint/no-empty-function onClearDrafts: () => { }, onchangeTimeout: 200, placeholder: '说点什么', menus: [ 'bold', 'italic', 'head', 'code', 'ol', 'ul', 'link', 'image', ' ', 'clear_drafts', ], tagsWhiteList: [ 'p', 'strong', 'b', 'em', 'i', 'h2', 'pre', 'code', 'ol', 'ul', 'li', 'a', 'img', 'figure', 'figcaption', ], autoSave: false, autoSaveKey: 'mdui-editor-content', imageUploadUrl: '', imageUploadMaxSize: 1024 * 1024 * 2, imageUploadSuffix: ['png', 'jpg', 'gif'], imageUploadName: 'file', imageUploadResponseTransform: false, }; /** * 编辑器 */ class Editor { /** * 初始化编辑器 * @param toolbar 工具栏的 CSS 选择器、或 DOM 元素、或 JQ 对象 * @param container 编辑器内容的 CSS 选择器、或 DOM 元素、或 JQ 对象 * @param options 配置参数 */ constructor(toolbar, container, options = {}) { /** * 配置参数 */ this.options = extend({}, DEFAULT_OPTIONS); /** * 输入内容时执行的函数 */ this.change = null; extend(this.options, options); this.$toolbar = $(toolbar).first().addClass('mdui_editor-toolbar'); this.$container = $(container) .first() .addClass('mdui_editor-content mdui-typo') .attr({ contenteditable: '', placeholder: this.options.placeholder, }); this.command = new Command(this); this.selection = new Selection(this); this.menus = new Menus(this); // 写入草稿的内容 if (this.options.autoSave) { this.setHTML(window.localStorage.getItem(this.options.autoSaveKey) || ''); } this.initSelection(true); this.bindEvent(); // 使用 p 换行 this.command.do('defaultParagraphSeparator', 'p'); // 禁止 IE 自动加链接 try { this.command.do('AutoUrlDetect', false); } catch (e) { /* eslint-disable no-empty */ } } /** * 初始化选区,将光标定位到内容尾部 * @param newLine 是否在内容后面添加一个空行 */ initSelection(newLine = false) { const $children = this.$container.children(); // 如果编辑器区域无内容,添加一个空行,重新设置选区 if (!$children.length) { this.$container.append('<p><br></p>'); return this.initSelection(); } const $last = $children.last(); // 最后一个元素不是 <p><br></p>,添加一个空行,重新设置选区 if (newLine) { const html = $last.html().toLowerCase(); const nodeName = $last[0].nodeName; if ((html !== '<br>' && html !== '<br/>') || nodeName !== 'P') { this.$container.append('<p><br></p>'); return this.initSelection(); } } this.updatePlaceholder(); this.selection.createRangeByElem($last, false, true); this.selection.restore(); } /** * 获取编辑器 html */ getHTML() { return this.$container.html().replace(/\u200b/gm, ''); } /** * 设置编辑器 html * @param html */ setHTML(html) { this.$container.html(html); this.initSelection(); } /** * 获取编辑器纯文本内容 */ getText() { return this.$container.text().replace(/\u200b/gm, ''); } /** * 设置编辑器纯文本内容 * @param text */ setText(text) { this.setHTML(text ? `<p>${text}</p>` : '<p><br></p>'); } /** * 清空编辑器内容 */ clear() { this.setHTML('<p><br></p>'); } /** * 聚焦到输入框 */ focus() { this.initSelection(); } /** * 绑定事件 * @private */ bindEvent() { // 记录输入法的开始和结束 let compositionEnd = true; this.$container // 输入法开始输入 .on('compositionstart', () => { compositionEnd = false; }) // 输入法结束输入 .on('compositionend', () => { compositionEnd = true; }) // 绑定 onchange .on('click keyup', () => { if (compositionEnd && this.change) { this.change(); } }); this.$toolbar.on('click', () => { if (this.change) { this.change(); } }); this.bindChange(); this.saveRangeRealTime(); this.pasteHandler(); this.deleteHandler(); this.containerClickHandler(); this.dragHandler(); this.undoHandler(); } /** * 更新 placeholder 显示状态 */ updatePlaceholder() { const className = 'mdui_editor-content-empty'; this.$container.html() === '<p><br></p>' ? this.$container.addClass(className) : this.$container.removeClass(className); } /** * 绑定 onchange 事件 * @private */ bindChange() { const options = this.options; const onchangeTimeout = options.onchangeTimeout; let onchangeTimeoutId = 0; let beforeChangeHTML = this.getHTML(); // 触发 change 的有三个场景: // 1. editor.$container.on('click keyup') // 2. editor.$toolbar.on('click') // 3. editor.command.do() this.change = () => { const currentHTML = this.getHTML(); // 内容没有变化,则不处理 if (currentHTML === beforeChangeHTML) { return; } // 执行,使用节流 if (onchangeTimeoutId) { clearTimeout(onchangeTimeoutId); } onchangeTimeoutId = setTimeout(() => { // 触发配置参数中的 onchange 函数 options.onchange(this); beforeChangeHTML = currentHTML; // 保存到 localStorage if (options.autoSave) { window.localStorage.setItem(options.autoSaveKey, this.getHTML()); } // 更新 placeholder 显示状态 this.updatePlaceholder(); }, onchangeTimeout); }; } /** * 实时保存选区 */ saveRangeRealTime() { // 保存当前的选区 const saveRange = () => { // 随时保存选区 this.selection.saveRange(); // 更新按钮状态 this.menus.changeStatus(); }; this.$container .on('keyup', saveRange) .on('mousedown', () => { // mousedown 状态下,鼠标滑动到编辑区域外面,也需要保存选区 this.$container.on('mouseleave', saveRange); }) .on('mouseup', () => { saveRange(); // 在编辑器区域之内完成点击,取消鼠标滑动到编辑区外面的事件 this.$container.off('mouseleave', saveRange); }); } /** * 粘贴文字、图片事件 */ pasteHandler() { this.$container.on('paste', (event) => { event.preventDefault(); // 获取粘贴的文字 const pasteHTML = purifier(getPasteText(event)); // todo 后续需要通过 getPasteHTML 获取内容,并进行过滤 const pasteText = getPasteText(event); const $selectionElem = this.selection.getContainerElem(); if (!$selectionElem.length) { return; } const { nodeName } = $selectionElem[0]; // 代码块中只能粘贴纯文本 if (nodeName === 'CODE' || nodeName === 'PRE') { this.command.do('insertHTML', pasteText); return; } if (!pasteHTML) { return; } try { // firefox 中,获取的 pasteHtml 可能是没有 <ul> 包裹的 <li> // 因此执行 insertHTML 会报错 this.command.do('insertHTML', pasteHTML); } catch (ex) { // 此时使用 pasteText 来兼容一下 this.command.do('insertHTML', pasteText); } }); } /** * 按删除键时的处理 */ deleteHandler() { this.$container.on('keydown keyup', (event) => { const { keyCode, type } = event; if (keyCode === 8 || keyCode === 46) { // 按删除键时,始终保留最后一个空行 const html = this.$container.html().toLowerCase().trim(); if (type === 'keydown') { if (html === '<p><br></p>') { event.preventDefault(); } else if (!html) { this.$container.html('<p><br></p>'); event.preventDefault(); } } if (type === 'keyup') { if (!html) { this.$container.html('<p><br></p>'); } } } this.updatePlaceholder(); }); } /** * 在 $container 上点击的处理 * @private */ containerClickHandler() { this.$container.on('click', (event) => { const target = event.target; if (!$(target).is(this.$container)) { return; } const $last = this.$container.children().last(); // 在 $container 上点击时,如果最后一行不是 p,则在最后添加一个空行,并聚焦 // $container 中不存在元素,或最后一行不是 p,添加一行 if (!$last.length || $last[0].nodeName !== 'P') { this.command.do('appendHTML', '<p><br></p>'); } }); } /** * 拖拽事件 */ dragHandler() { // 禁用编辑器内容拖拽事件 this.$container.on('dragleave drop dragenter dragover', false); // todo 编辑区域拖拽上传图片 } /** * Ctrl + Z 处理 */ undoHandler() { // undo 操作无法撤销直接操作 DOM 的清空,先直接禁用 undo,以后想办法 this.$container.on('keydown', (event) => { if (event.ctrlKey && event.keyCode === 90) { return false; } }); } } export default Editor;