mdui.editor
Version:
Material Design 样式的富文本编辑器
378 lines (377 loc) • 11.9 kB
JavaScript
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;