wangeditor
Version:
wangEditor - 轻量级 web 富文本编辑器,配置方便,使用简单,开源免费
367 lines (333 loc) • 11.4 kB
text/typescript
/**
* @description selection range API
* @author wangfupeng
*/
import $, { DomElement } from '../utils/dom-core'
import { UA } from '../utils/util'
import Editor from './index'
import { EMPTY_P } from '../utils/const'
class SelectionAndRange {
public editor: Editor
private _currentRange: Range | null | undefined = null
constructor(editor: Editor) {
this.editor = editor
}
/**
* 获取当前 range
*/
public getRange(): Range | null | undefined {
return this._currentRange
}
/**
* 保存选区范围
* @param _range 选区范围
*/
public saveRange(_range?: Range): void {
if (_range) {
// 保存已有选区
this._currentRange = _range
return
}
// 获取当前的选区
const selection = window.getSelection() as Selection
if (selection.rangeCount === 0) {
return
}
const range = selection.getRangeAt(0)
// 获取选区范围的 DOM 元素
const $containerElem = this.getSelectionContainerElem(range)
if (!$containerElem?.length) {
// 当 选区范围内没有 DOM元素 则抛出
return
}
if (
$containerElem.attr('contenteditable') === 'false' ||
$containerElem.parentUntil('[contenteditable=false]')
) {
// 这里大体意义上就是个保险
// 确保 编辑区域 的 contenteditable属性 的值为 true
return
}
const editor = this.editor
const $textElem = editor.$textElem
if ($textElem.isContain($containerElem)) {
if ($textElem.elems[0] === $containerElem.elems[0]) {
if ($textElem.html().trim() === EMPTY_P) {
const $children = $textElem.children()
const $last = $children?.last()
editor.selection.createRangeByElem($last as DomElement, true, true)
editor.selection.restoreSelection()
}
}
// 是编辑内容之内的
this._currentRange = range
}
}
/**
* 折叠选区范围
* @param toStart true 开始位置,false 结束位置
*/
public collapseRange(toStart: boolean = false): void {
const range = this._currentRange
if (range) {
range.collapse(toStart)
}
}
/**
* 获取选区范围内的文字
*/
public getSelectionText(): string {
const range = this._currentRange
if (range) {
return range.toString()
} else {
return ''
}
}
/**
* 获取选区范围的 DOM 元素
* @param range 选区范围
*/
public getSelectionContainerElem(range?: Range): DomElement | undefined {
let r: Range | null | undefined
r = range || this._currentRange
let elem: Node
if (r) {
elem = r.commonAncestorContainer
return $(elem.nodeType === 1 ? elem : elem.parentNode)
}
}
/**
* 选区范围开始的 DOM 元素
* @param range 选区范围
*/
public getSelectionStartElem(range?: Range): DomElement | undefined {
let r: Range | null | undefined
r = range || this._currentRange
let elem: Node
if (r) {
elem = r.startContainer
return $(elem.nodeType === 1 ? elem : elem.parentNode)
}
}
/**
* 选区范围结束的 DOM 元素
* @param range 选区范围
*/
public getSelectionEndElem(range?: Range): DomElement | undefined {
let r: Range | null | undefined
r = range || this._currentRange
let elem: Node
if (r) {
elem = r.endContainer
return $(elem.nodeType === 1 ? elem : elem.parentNode)
}
}
/**
* 选区是否为空(没有选择文字)
*/
public isSelectionEmpty(): boolean {
const range = this._currentRange
if (range && range.startContainer) {
if (range.startContainer === range.endContainer) {
if (range.startOffset === range.endOffset) {
return true
}
}
}
return false
}
/**
* 恢复选区范围
*/
public restoreSelection(): void {
const selection = window.getSelection()
const r = this._currentRange
if (selection && r) {
selection.removeAllRanges()
selection.addRange(r)
}
}
/**
* 创建一个空白(即 ​ 字符)选区
*/
public createEmptyRange(): void {
const editor = this.editor
const range = this.getRange()
let $elem: DomElement
if (!range) {
// 当前无 range
return
}
if (!this.isSelectionEmpty()) {
// 当前选区必须没有内容才可以,有内容就直接 return
return
}
try {
// 目前只支持 webkit 内核
if (UA.isWebkit()) {
// 插入 ​
editor.cmd.do('insertHTML', '​')
// 修改 offset 位置
range.setEnd(range.endContainer, range.endOffset + 1)
// 存储
this.saveRange(range)
} else {
$elem = $('<strong>​</strong>')
editor.cmd.do('insertElem', $elem)
this.createRangeByElem($elem, true)
}
} catch (ex) {
// 部分情况下会报错,兼容一下
}
}
/**
* 重新设置选区
* @param startDom 选区开始的元素
* @param endDom 选区结束的元素
*/
public createRangeByElems(startDom: Node, endDom: Node): void {
let selection = window.getSelection ? window.getSelection() : document.getSelection()
//清除所有的选区
selection?.removeAllRanges()
const range = document.createRange()
range.setStart(startDom, 0)
// 设置多行标签之后,第二个参数会被h标签内的b、font标签等影响range范围的选取
range.setEnd(endDom, endDom.childNodes.length || 1)
// 保存设置好的选区
this.saveRange(range)
//恢复选区
this.restoreSelection()
}
/**
* 根据 DOM 元素设置选区
* @param $elem DOM 元素
* @param toStart true 开始位置,false 结束位置
* @param isContent 是否选中 $elem 的内容
*/
public createRangeByElem($elem: DomElement, toStart?: boolean, isContent?: boolean): void {
if (!$elem.length) {
return
}
const elem = $elem.elems[0]
const range = document.createRange()
if (isContent) {
range.selectNodeContents(elem)
} else {
// 如果用户没有传入 isContent 参数,那就默认为 false
range.selectNode(elem)
}
if (toStart != null) {
// 传入了 toStart 参数,折叠选区。如果没传入 toStart 参数,则忽略这一步
range.collapse(toStart)
if (!toStart) {
this.saveRange(range)
this.editor.selection.moveCursor(elem)
}
}
// 存储 range
this.saveRange(range)
}
/**
* 获取 当前 选取范围的 顶级(段落) 元素
* @param $editor
*/
public getSelectionRangeTopNodes(): DomElement[] {
// 清空,防止叠加元素
let $nodeList: DomElement[]
const $startElem = this.getSelectionStartElem()?.getNodeTop(this.editor)
const $endElem = this.getSelectionEndElem()?.getNodeTop(this.editor)
$nodeList = this.recordSelectionNodes($($startElem), $($endElem))
return $nodeList
}
/**
* 移动光标位置,默认情况下在尾部
* 有一个特殊情况是firefox下的文本节点会自动补充一个br元素,会导致自动换行
* 所以默认情况下在firefox下的文本节点会自动移动到br前面
* @param {Node} node 元素节点
* @param {number} position 光标的位置
*/
public moveCursor(node: Node, position?: number): void {
const range = this.getRange()
//对文本节点特殊处理
let len: number =
node.nodeType === 3 ? (node.nodeValue?.length as number) : node.childNodes.length
if ((UA.isFirefox || UA.isIE()) && len !== 0) {
// firefox下在节点为文本节点和节点最后一个元素为文本节点的情况下
if (node.nodeType === 3 || node.childNodes[len - 1].nodeName === 'BR') {
len = len - 1
}
}
let pos: number = position ?? len
if (!range) {
return
}
if (node) {
range.setStart(node, pos)
range.setEnd(node, pos)
this.restoreSelection()
}
}
/**
* 获取光标在当前选区的位置
*/
public getCursorPos(): number | undefined {
const selection = window.getSelection()
return selection?.anchorOffset
}
/**
* 清除当前选区的Range,notice:不影响已保存的Range
*/
public clearWindowSelectionRange(): void {
const selection = window.getSelection()
if (selection) {
selection.removeAllRanges()
}
}
/**
* 记录节点 - 从选区开始节点开始 一直到匹配到选区结束节点为止
* @param $node 节点
*/
public recordSelectionNodes($node: DomElement, $endElem: DomElement): DomElement[] {
let $list: DomElement[] = []
let isEnd = true
/**
@author:lw
@description 解决ctrl+a全选报错的bug $elem.getNodeName()可能会触发$elem[0]未定义
**/
try {
let $NODE: DomElement = $node
const $textElem = this.editor.$textElem
// $NODE元素为空时不需要进行循环
while (isEnd) {
const $elem = $NODE?.getNodeTop(this.editor)
if ($elem.getNodeName() === 'BODY') isEnd = false // 兜底
if ($elem.length > 0) {
$list.push($($NODE))
// 两个边界情况:
// 1. 当前元素就是我们要找的末尾元素
// 2. 当前元素已经是编辑区顶级元素(否则会找到编辑区的兄弟节点,比如placeholder元素)
if ($endElem?.equal($elem) || $textElem.equal($elem)) {
isEnd = false
} else {
$NODE = $elem.getNextSibling()
}
}
}
} catch (e) {
isEnd = false
}
return $list
}
/**
* 将当前 range 设置到 node 元素并初始化位置
* 解决编辑器内容为空时,菜单不生效的问题
* @param node 元素节点
*/
public setRangeToElem(node: Node): void {
const range = this.getRange()
range?.setStart(node, 0)
range?.setEnd(node, 0)
}
}
export default SelectionAndRange