UNPKG

wangeditor

Version:

wangEditor - 轻量级 web 富文本编辑器,配置方便,使用简单,开源免费

604 lines (527 loc) 20.9 kB
/** * @description 编辑区域,入口文件 * @author wangfupeng */ import $, { DomElement } from '../utils/dom-core' import Editor from '../editor/index' import initEventHooks from './event-hooks/index' import { UA, throttle } from '../utils/util' import getChildrenJSON, { NodeListType } from './getChildrenJSON' import getHtmlByNodeList from './getHtmlByNodeList' import { EMPTY_P, EMPTY_P_LAST_REGEX, EMPTY_P_REGEX } from '../utils/const' /** 按键函数 */ type KeyBoardHandler = (event: KeyboardEvent) => unknown /** 普通事件回调 */ type EventHandler = (event?: Event) => unknown // 各个事件钩子函数 type TextEventHooks = { onBlurEvents: EventHandler[] changeEvents: (() => void)[] // 内容修改时 dropEvents: ((event: DragEvent) => unknown)[] clickEvents: EventHandler[] keydownEvents: KeyBoardHandler[] keyupEvents: KeyBoardHandler[] /** tab 键(keyCode === )Up 时 */ tabUpEvents: KeyBoardHandler[] /** tab 键(keyCode === 9)Down 时 */ tabDownEvents: KeyBoardHandler[] /** enter 键(keyCode === 13)up 时 */ enterUpEvents: KeyBoardHandler[] /** enter 键(keyCode === 13)down 时 */ enterDownEvents: KeyBoardHandler[] /** 删除键(keyCode === 8)up 时 */ deleteUpEvents: KeyBoardHandler[] /** 删除键(keyCode === 8)down 时 */ deleteDownEvents: KeyBoardHandler[] /** 粘贴事件 */ pasteEvents: ((e: ClipboardEvent) => void)[] /** 点击链接事件 */ linkClickEvents: ((e: DomElement) => void)[] /** 点击代码事件 */ codeClickEvents: ((e: DomElement) => void)[] /** 编辑区域滑动事件 */ textScrollEvents: EventHandler[] /** 菜单栏被点击 */ toolbarClickEvents: EventHandler[] /** 图片被点击事件 */ imgClickEvents: ((e: DomElement) => void)[] /** 图片拖拽MouseDown */ imgDragBarMouseDownEvents: (() => void)[] /** 表格点击 */ tableClickEvents: (($dom: DomElement, e: MouseEvent) => void)[] /** 每个菜单被点击时,按理说这个不属于 txt 的,先暂时在这放着吧 */ menuClickEvents: (() => void)[] /** droplist 菜单悬浮事件。暂时放这里 */ dropListMenuHoverEvents: (() => void)[] /** 点击分割线时 */ splitLineEvents: ((e: DomElement) => void)[] /** 视频点击事件 */ videoClickEvents: ((e: DomElement) => void)[] } class Text { public editor: Editor public eventHooks: TextEventHooks // Text 各个事件的钩子函数,如 keyup 时要执行哪些函数 constructor(editor: Editor) { this.editor = editor this.eventHooks = { onBlurEvents: [], changeEvents: [], dropEvents: [], clickEvents: [], keydownEvents: [], keyupEvents: [], tabUpEvents: [], tabDownEvents: [], enterUpEvents: [], enterDownEvents: [], deleteUpEvents: [], deleteDownEvents: [], pasteEvents: [], linkClickEvents: [], codeClickEvents: [], textScrollEvents: [], toolbarClickEvents: [], imgClickEvents: [], imgDragBarMouseDownEvents: [], tableClickEvents: [], menuClickEvents: [], dropListMenuHoverEvents: [], splitLineEvents: [], videoClickEvents: [], } } /** * 初始化 */ public init(): void { // 实时保存选取范围 this._saveRange() // 绑定事件 this._bindEventHooks() // 初始化 text 事件钩子函数 initEventHooks(this) } /** * 切换placeholder */ public togglePlaceholder(): void { const html = this.html() const $placeholder = this.editor.$textContainerElem.find('.placeholder') $placeholder.hide() if (this.editor.isComposing) return if (!html || html === ' ') $placeholder.show() } /** * 清空内容 */ public clear(): void { this.html(EMPTY_P) } /** * 设置/获取 html * @param val html 字符串 */ public html(val?: string): void | string { const editor = this.editor const $textElem = editor.$textElem // 没有 val ,则是获取 html if (val == null) { let html = $textElem.html() // 未选中任何内容的时候点击“加粗”或者“斜体”等按钮,就得需要一个空的占位符 &#8203 ,这里替换掉 html = html.replace(/\u200b/gm, '') // 去掉空行 html = html.replace(/<p><\/p>/gim, '') // 去掉最后的 空标签 html = html.replace(EMPTY_P_LAST_REGEX, '') // 为了避免用户在最后生成的EMPTY_P标签中编辑数据, 最后产生多余标签, 去除所有p标签上的data-we-empty-p属性 html = html.replace(EMPTY_P_REGEX, '<p>') /** * 这里的代码为了处理火狐多余的空行标签,但是强制删除空行标签会带来其他问题 * html()方法返回的的值,EMPTY_P中pr会被删除,只留下<p>,点不进去,从而产生垃圾数据 * 目前在末位有多个空行的情况下执行撤销重做操作,会产生一种不记录末尾空行的错觉 * 暂时注释, 等待进一步的兼容处理 */ // html = html.replace(/><br>(?!<)/gi, '>') // 过滤 <p><br>内容</p> 中的br // html = html.replace(/(?!>)<br></gi, '<') // 过滤 <p>内容<br></p> 中的br /** * pre标签格式化 * html()方法理论上应当输出纯净的代码文本,但是对于是否解析html标签还没有良好的判断 * 如果去除hljs的标签,在解析状态下回显,会造成显示错误并且无法再通过hljs方法渲染 * 暂且其弃用 */ // html = formatCodeHtml(editor, html) // 将没有自闭和的标签过滤为自闭和 const selfCloseHtmls: RegExpMatchArray | null = html.match(/<(img|br|hr|input)[^>]*>/gi) if (selfCloseHtmls !== null) { selfCloseHtmls.forEach(item => { if (!item.match(/\/>/)) { html = html.replace(item, item.substring(0, item.length - 1) + '/>') } }) } return html } // 有 val ,则是设置 html val = val.trim() if (val === '') { val = EMPTY_P } if (val.indexOf('<') !== 0) { // 内容用 p 标签包裹 val = `<p>${val}</p>` } $textElem.html(val) // 初始化选区,将光标定位到内容尾部 editor.initSelection() } /** * 将json设置成html至编辑器 * @param nodeList json格式 */ public setJSON(nodeList: NodeListType): void { const html = getHtmlByNodeList(nodeList).children() const editor = this.editor const $textElem = editor.$textElem // 没有获取到元素的情况 if (!html) return // 替换文本节点下全部子节点 $textElem.replaceChildAll(html) } /** * 获取 json 格式的数据 */ public getJSON(): NodeListType { const editor = this.editor const $textElem = editor.$textElem return getChildrenJSON($textElem) } /** * 设置 字符串内容 * @param val text 字符串 */ public text(val: string): void /** * 获取 字符串内容 */ public text(): string public text(val?: string): void | string { const editor = this.editor const $textElem = editor.$textElem // 没有 val ,是获取 text if (val == null) { let text = $textElem.text() // 未选中任何内容的时候点击“加粗”或者“斜体”等按钮,就得需要一个空的占位符 &#8203 ,这里替换掉 text = text.replace(/\u200b/gm, '') return text } // 有 val ,则是设置 text $textElem.text(`<p>${val}</p>`) // 初始化选区,将光标定位到内容尾部 editor.initSelection() } /** * 追加 html 内容 * @param html html 字符串 */ public append(html: string): void { const editor = this.editor if (html.indexOf('<') !== 0) { // 普通字符串,用 <p> 包裹 html = `<p>${html}</p>` } this.html(this.html() + html) // 初始化选区,将光标定位到内容尾部 editor.initSelection() } /** * 每一步操作,都实时保存选区范围 */ private _saveRange(): void { const editor = this.editor const $textElem = editor.$textElem const $document = $(document) // 保存当前的选区 function saveRange() { // 随时保存选区 editor.selection.saveRange() // 更新按钮 active 状态 editor.menus.changeActive() } // 按键后保存 $textElem.on('keyup', saveRange) // 点击后保存,为了避免被多次执行而导致造成浪费,这里对 click 使用一次性绑定 function onceClickSaveRange() { saveRange() $textElem.off('click', onceClickSaveRange) } $textElem.on('click', onceClickSaveRange) function handleMouseUp() { // 在编辑器区域之外完成抬起,保存此时编辑区内的新选区,取消此时鼠标抬起事件 saveRange() $document.off('mouseup', handleMouseUp) } function listenMouseLeave() { // 当鼠标移动到外面,要监听鼠标抬起操作 $document.on('mouseup', handleMouseUp) // 首次移出时即接触leave监听,防止用户不断移入移出多次注册handleMouseUp $textElem.off('mouseleave', listenMouseLeave) } $textElem.on('mousedown', () => { // mousedown 状态下,要坚听鼠标滑动到编辑区域外面 $textElem.on('mouseleave', listenMouseLeave) }) $textElem.on('mouseup', (e: MouseEvent) => { // 记得移除$textElem的mouseleave事件, 避免内存泄露 $textElem.off('mouseleave', listenMouseLeave) // fix:避免当选中一段文字之后,再次点击文字中间位置无法更新selection问题。issue#3096 setTimeout(() => { const selection = editor.selection const range = selection.getRange() if (range === null) return saveRange() }, 0) }) } /** * 绑定事件,事件会触发钩子函数 */ private _bindEventHooks(): void { const editor = this.editor const $textElem = editor.$textElem const eventHooks = this.eventHooks // click hooks $textElem.on('click', (e: Event) => { const clickEvents = eventHooks.clickEvents clickEvents.forEach(fn => fn(e)) }) // enter 键 up 时的 hooks $textElem.on('keyup', (e: KeyboardEvent) => { if (e.keyCode !== 13) return const enterUpEvents = eventHooks.enterUpEvents enterUpEvents.forEach(fn => fn(e)) }) // 键盘 up 时的 hooks $textElem.on('keyup', (e: KeyboardEvent) => { const keyupEvents = eventHooks.keyupEvents keyupEvents.forEach(fn => fn(e)) }) // 键盘 down 时的 hooks $textElem.on('keydown', (e: KeyboardEvent) => { const keydownEvents = eventHooks.keydownEvents keydownEvents.forEach(fn => fn(e)) }) // delete 键 up 时 hooks $textElem.on('keyup', (e: KeyboardEvent) => { if (e.keyCode !== 8 && e.keyCode !== 46) return const deleteUpEvents = eventHooks.deleteUpEvents deleteUpEvents.forEach(fn => fn(e)) }) // delete 键 down 时 hooks $textElem.on('keydown', (e: KeyboardEvent) => { if (e.keyCode !== 8 && e.keyCode !== 46) return const deleteDownEvents = eventHooks.deleteDownEvents deleteDownEvents.forEach(fn => fn(e)) }) // 粘贴 $textElem.on('paste', (e: ClipboardEvent) => { if (UA.isIE()) return // IE 不支持 // 阻止默认行为,使用 execCommand 的粘贴命令 e.preventDefault() const pasteEvents = eventHooks.pasteEvents pasteEvents.forEach(fn => fn(e)) }) // 撤销/恢复 快捷键 $textElem.on('keydown', (e: KeyboardEvent) => { if ( // 编辑器处于聚焦状态下(多编辑器实例) || 当前处于兼容模式(兼容模式撤销/恢复后不聚焦,所以直接过,但会造成多编辑器同时撤销/恢复) (editor.isFocus || editor.isCompatibleMode) && (e.ctrlKey || e.metaKey) && e.keyCode === 90 ) { // 取消默认行为 e.preventDefault() // 执行事件 if (e.shiftKey) { // 恢复 editor.history.restore() } else { // 撤销 editor.history.revoke() } } }) // tab up $textElem.on('keyup', (e: KeyboardEvent) => { if (e.keyCode !== 9) return e.preventDefault() const tabUpEvents = eventHooks.tabUpEvents tabUpEvents.forEach(fn => fn(e)) }) // tab down $textElem.on('keydown', (e: KeyboardEvent) => { if (e.keyCode !== 9) return e.preventDefault() const tabDownEvents = eventHooks.tabDownEvents tabDownEvents.forEach(fn => fn(e)) }) // 文本编辑区域 滚动时触发 $textElem.on( 'scroll', // 使用节流 throttle((e: Event) => { const textScrollEvents = eventHooks.textScrollEvents textScrollEvents.forEach(fn => fn(e)) }, 100) ) // 拖拽相关的事件 function preventDefault(e: Event) { // 禁用 document 拖拽事件 e.preventDefault() } $(document) .on('dragleave', preventDefault) .on('drop', preventDefault) .on('dragenter', preventDefault) .on('dragover', preventDefault) // 全局事件在编辑器实例销毁的时候进行解绑 editor.beforeDestroy(function () { $(document) .off('dragleave', preventDefault) .off('drop', preventDefault) .off('dragenter', preventDefault) .off('dragover', preventDefault) }) $textElem.on('drop', (e: DragEvent) => { e.preventDefault() const events = eventHooks.dropEvents events.forEach(fn => fn(e)) }) // link click $textElem.on('click', (e: Event) => { // 存储链接元素 let $link: DomElement | null = null const target = e.target as HTMLElement const $target = $(target) if ($target.getNodeName() === 'A') { // 当前点击的就是一个链接 $link = $target } else { // 否则,向父节点中寻找链接 const $parent = $target.parentUntil('a') if ($parent != null) { // 找到了 $link = $parent } } if (!$link) return // 没有点击链接,则返回 const linkClickEvents = eventHooks.linkClickEvents linkClickEvents.forEach(fn => fn($link as DomElement)) }) // img click $textElem.on('click', (e: Event) => { // 存储图片元素 let $img: DomElement | null = null const target = e.target as HTMLElement const $target = $(target) //处理图片点击 去除掉emoji图片的情况 if ($target.getNodeName() === 'IMG' && !$target.elems[0].getAttribute('data-emoji')) { // 当前点击的就是img e.stopPropagation() $img = $target } if (!$img) return // 没有点击图片,则返回 const imgClickEvents = eventHooks.imgClickEvents imgClickEvents.forEach(fn => fn($img as DomElement)) }) // code click $textElem.on('click', (e: Event) => { // 存储代码元素 let $code: DomElement | null = null const target = e.target as HTMLElement const $target = $(target) if ($target.getNodeName() === 'PRE') { // 当前点击的就是一个链接 $code = $target } else { // 否则,向父节点中寻找链接 const $parent = $target.parentUntil('pre') if ($parent !== null) { // 找到了 $code = $parent } } if (!$code) return const codeClickEvents = eventHooks.codeClickEvents codeClickEvents.forEach(fn => fn($code as DomElement)) }) // splitLine click $textElem.on('click', (e: Event) => { // 存储分割线元素 let $splitLine: DomElement | null = null const target = e.target as HTMLElement const $target = $(target) // 判断当前点击元素 if ($target.getNodeName() === 'HR') { $splitLine = $target } else { $target == null } if (!$splitLine) return // 没有点击分割线,则返回 // 设置、恢复选区 editor.selection.createRangeByElem($splitLine) editor.selection.restoreSelection() const splitLineClickEvents = eventHooks.splitLineEvents splitLineClickEvents.forEach(fn => fn($splitLine as DomElement)) }) // 菜单栏被点击 editor.$toolbarElem.on('click', (e: Event) => { const toolbarClickEvents = eventHooks.toolbarClickEvents toolbarClickEvents.forEach(fn => fn(e)) }) //mousedown事件 editor.$textContainerElem.on('mousedown', (e: MouseEvent) => { const target = e.target as HTMLElement const $target = $(target) if ($target.hasClass('w-e-img-drag-rb')) { // 点击的元素,是图片拖拽调整大小的 bar const imgDragBarMouseDownEvents = eventHooks.imgDragBarMouseDownEvents imgDragBarMouseDownEvents.forEach(fn => fn()) } }) //table click $textElem.on('click', (e: MouseEvent) => { // 存储元素 let $dom: DomElement | null = null const target = e.target as HTMLElement //获取最祖父元素 $dom = $(target).parentUntilEditor('TABLE', editor, target) // 没有table范围内,则返回 if (!$dom) return const tableClickEvents = eventHooks.tableClickEvents tableClickEvents.forEach(fn => fn($dom as DomElement, e)) }) // enter 键 down $textElem.on('keydown', (e: KeyboardEvent) => { if (e.keyCode !== 13) return const enterDownEvents = eventHooks.enterDownEvents enterDownEvents.forEach(fn => fn(e)) }) // 视频 click $textElem.on('click', (e: Event) => { // 存储视频 let $video: DomElement | null = null const target = e.target as HTMLElement const $target = $(target) //处理视频点击 简单的video 标签 if ($target.getNodeName() === 'VIDEO') { // 当前点击的就是视频 e.stopPropagation() $video = $target } if (!$video) return // 没有点击视频,则返回 const videoClickEvents = eventHooks.videoClickEvents videoClickEvents.forEach(fn => fn($video as DomElement)) }) } } export default Text