>  기사  >  웹 프론트엔드  >  자바스크립트를 사용하여 서식 있는 텍스트 편집기 구현

자바스크립트를 사용하여 서식 있는 텍스트 편집기 구현

php中世界最好的语言
php中世界最好的语言원래의
2017-11-18 13:20:593899검색

최근 프로젝트에서는 몇 가지 특별한 사용자 정의 기능이 포함된 PC 및 모바일 단말기와 호환되는 서식 있는 텍스트 편집기를 개발해야 하기 때문입니다. 그래서 기존의 자바스크립트 리치 텍스트 편집기를 살펴보았는데, 데스크톱 쪽에는 많은데 모바일 쪽에는 거의 없습니다. 데스크탑 측은 UEditor로 표현됩니다. 하지만 당분간은 호환성을 고려하지 않기 때문에 UEditor처럼 무거운 플러그인을 사용할 필요는 없습니다. 이런 이유로 리치 텍스트 편집기를 구현하기 위해 자바스크립트를 사용하기로 결정했습니다. 이 기사에서는 주로 서식 있는 텍스트 편집기를 구현하는 방법과 다양한 브라우저 및 장치 간의 일부 버그를 해결하는 방법을 소개합니다.

준비 단계

html이 서식 있는 텍스트 편집 기능을 지원할 수 있도록 최신 브라우저에 많은 API가 준비되어 있습니다. 전체 콘텐츠를 직접 완성할 필요는 없습니다.

contenteditable=”true”

먼저 contenteditable="true" 속성을 추가하여 div를 편집 가능하게 만들어야 합니다.

<div contenteditable="true" id="rich-editor"></div>

이러한 dc6dce4a544fdca2df29d5ac0ea9906b에 삽입된 모든 노드는 기본적으로 편집 가능합니다. 편집 불가능한 노드를 삽입하려면 삽입된 노드의 속성을 contenteditable="false"로 지정해야 합니다.

커서 조작

리치 텍스트 편집기로서 개발자는 커서의 다양한 상태 정보, 위치 정보 등을 제어할 수 있어야 합니다. 브라우저는 커서를 조작하기 위한 선택 개체와 범위 개체를 제공합니다.

selection 개체

Selection 개체는 사용자가 선택한 텍스트 범위 또는 캐럿의 현재 위치를 나타냅니다. 이는 여러 요소에 걸쳐 있을 수 있는 페이지의 텍스트 선택을 나타냅니다. 텍스트 선택은 사용자가 텍스트 위로 마우스를 드래그하여 생성됩니다.

선택 개체 가져오기

let selection = window.getSelection();

일반적으로 선택 개체를 직접 조작하지는 않지만, 일반적으로 "드래그 블루"라고 알려진 선택 개체에 해당하는 사용자가 선택한 범위(영역)를 조작해야 합니다. 획득 방법은 다음과 같습니다:

let range = selection.getRangeAt(0);

현재 브라우저에 여러 텍스트 선택 항목이 있을 수 있으므로 getRangeAt 함수는 인덱스 값을 허용합니다. 서식 있는 텍스트 편집에서는 다중 선택 가능성을 고려하지 않습니다.

selection 개체에는 addRange 및 RemoveAllRanges라는 두 가지 중요한 메서드도 있습니다. 현재 선택 항목에 범위 개체를 추가하고 모든 범위 개체를 각각 삭제하는 데 사용됩니다. 나중에 그 용도를 살펴보겠습니다.

range object

선택 개체를 통해 얻은 범위 개체가 커서 작업의 초점입니다. Range는 노드와 부분 텍스트 노드를 포함하는 문서 조각을 나타냅니다. 레인지 오브제를 처음 봤을 때 낯설기도 하고 익숙하기도 하더군요. 어디서 보셨나요? 프론트엔드 엔지니어라면 "JavaScript Advanced 프로그래밍 제3판"이라는 책을 읽어보셨을 것입니다. 섹션 12.4에서 저자는 페이지를 더 잘 제어하기 위해 DOM2 레벨에서 제공하는 범위 인터페이스를 소개합니다. 그나저나 그 때 나는 어떤 모습이었나요? ? ? ? 이것이 무슨 소용이 있겠습니까? 여기서는 이 객체를 광범위하게 사용합니다. 다음 노드의 경우:

<div contenteditable="true" id="rich-editor">
    <p>123</p>
</div>

이번에는 범위 개체를 인쇄합니다.

* startContainer: 범위의 시작 노드입니다.

* endContainer: 범위의 끝 노드

* startOffset: 범위 시작 위치의 오프셋입니다.

* endOffset: 범위 끝 위치의 오프셋입니다.

* commonAncestorContainer: startContainer 및 endContainer를 포함하는 가장 깊은 노드를 반환합니다.

*collapsed: 범위의 시작 위치와 끝 위치가 동일한지 여부를 결정하는 데 사용되는 부울 값을 반환합니다.

여기서 startContainer, endContainer, commonAncestorContainer는 모두 #text 텍스트 노드 'Baidu EUX Team'입니다. 커서가 'degree'라는 단어 뒤에 있으므로 startOffset과 endOffset은 모두 2입니다. 그리고 드래그가 생성되지 않으므로 Collapsed 값은 true입니다.

지연으로 인해 startContainer와 endContainer는 더 이상 일관되지 않으며,collapsed 값은 false가 됩니다. startOffset 및 endOffset은 파란색 드래그의 시작 및 끝 위치를 정확하게 나타냅니다. 더 많은 효과를 보려면 직접 시도해 볼 수 있습니다.

range 노드를 동작시키는 방법은 주로 다음과 같은 메소드가 있습니다.

setStart(): Range의 시작점을 설정합니다.

setEnd(): Range의 끝점을 설정합니다.

selectNode(): Range를 설정합니다. 노드와 노드 내용을 포함하는

collapse(): Range를 지정된 끝점으로 축소

insertNode(): Range의 시작점에 노드를 삽입합니다.

cloneRange(): 원래 Range와 동일한 끝점을 가진 복제 Range 개체를 반환합니다.

서식 있는 텍스트 편집에 일반적으로 사용되는 메서드가 너무 많고 나열하지 않을 메서드도 더 많습니다.

커서 위치 수정

setStart() 및 setEnd() 메소드를 호출하여 커서 위치나 드래그 범위를 수정할 수 있습니다. 이 두 가지 방법에서 허용되는 매개변수는 각각의 시작 및 끝 노드와 오프셋입니다. 예를 들어 커서 위치를 "Baidu EUX Team" 끝에 두려면 다음 방법을 사용할 수 있습니다.

let range = window.getSelection().getRangeAt(0),
    textEle = range.commonAncestorContainer;
range.setStart(range.startContainer, textEle.length);
range.setEnd(range.endContainer, textEle.length);

효과를 확인하기 위해 타이머를 추가합니다.

그러나 이 방법에는 제한이 있습니다. , 즉 커서가 위치한 노드에 변화가 생기면. 예를 들어 노드가 교체되거나 새 노드가 추가된 경우 이 방법을 다시 사용해도 아무런 효과가 없습니다. 이러한 이유로 때때로 커서 위치를 강제로 변경하는 수단이 필요합니다. 간단한 코드는 다음과 같습니다(실제로는 자체 닫기 및 요소도 고려해야 할 수도 있습니다).

function resetRange(startContainer, startOffset, endContainer, endOffset) {
    let selection = window.getSelection();
        selection.removeAllRanges();
    let range = document.createRange();
    range.setStart(startContainer, startOffset);
    range.setEnd(endContainer, endOffset);
    selection.addRange(range);
}

범위 객체를 다시 생성하고 커서가 원하는 위치로 확실히 이동하도록 하려면 원래 범위를 삭제하세요.

修改文本格式

实现富文本编辑器,我们就要能够有修改文档格式的能力,比如加粗,斜体,文本颜色,列表等内容。DOM 为可编辑区提供了 document.execCommand 方法,该方法允许运行命令来操纵可编辑区域的内容。大多数命令影响文档的选择(粗体,斜体等),而其他命令插入新元素(添加链接)或影响整行(缩进)。当使用 contentEditable时,调用 execCommand() 将影响当前活动的可编辑元素。语法如下:

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

aCommandName: 一个 DOMString ,命令的名称。可用命令列表请参阅 命令 。

aShowDefaultUI: 一个 Boolean, 是否展示用户界面,一般为 false。Mozilla 没有实现。

aValueArgument: 一些命令(例如insertImage)需要额外的参数(insertImage需要提供插入image的url),默认为null。

总之浏览器能把大部分我们想到的富文本编辑器需要的功能都实现了,这里我就不一一演示了。感兴趣的同学可以查看 MDN – document.execCommand。

到这里,我相信你已经可以做出一个像模像样的富文本编辑器了。想想还挺激动的,但是呢,一切都没有结束,浏览器又一次坑了我们。

实战开始,填坑的旅途

就在我们都以为开发如此简单的时候,实际上手却遇到了许多坑。

修正浏览器的默认效果

浏览器提供的富文本效果并不总是好用的,下面介绍几个遇到的问题。

回车换行

当我们在编辑其中输入内容并回车换行继续输入后,可编辑框内容生成的节点和我们预期是不符的。

可以看到最先输入的文字没有被包裹起来,而换行产生的内容,包裹元素是 dc6dce4a544fdca2df29d5ac0ea9906b标签。为了能够让文字被 e388a4556c0f65e1904146cc1a846bee元素包裹起来。

我们要在初始化的时候,向dc6dce4a544fdca2df29d5ac0ea9906b默认插入 e388a4556c0f65e1904146cc1a846bee0c6dc11e160d3b678d68754cc175188a94b3e26ee717c64999d7867364b1b4a3元素

(0c6dc11e160d3b678d68754cc175188a标签用来占位,有内容输入后会自动删除)。这样以后每次回车产生的新内容都会被 e388a4556c0f65e1904146cc1a846bee元素包裹起来(在可编辑状态下,回车换行产生的新结构会默认拷贝之前的内容,包裹节点,类名等各种内容)。

我们还需要监听 keyUp 事件下 event.keyCode === 8 删除键。当编辑器中内容全被清空后(delete键也会把 e388a4556c0f65e1904146cc1a846bee标签删除),要重新加入e388a4556c0f65e1904146cc1a846bee0c6dc11e160d3b678d68754cc175188a94b3e26ee717c64999d7867364b1b4a3标签,并把光标定位在里面。

插入 ul 和 ol 位置错误

当我们调用 document.execCommand("insertUnorderedList", false, null) 来插入一个列表的时候,新的列表会被插入e388a4556c0f65e1904146cc1a846bee标签中。

为此我们需要每次调用该命令前做一次修正,参考代码如下:

function adjustList() {
    let lists = document.querySelectorAll("ol, ul");
     for (let i = 0; i < lists.length; i++) {
        let ele = lists[i]; // ol
        let parentNode = ele.parentNode;
        if (parentNode.tagName === &#39;P&#39; && parentNode.lastChild ===parentNode.firstChild) {
                parentNode.insertAdjacentElement(&#39;beforebegin&#39;, ele);
                parentNode.remove()
        }
    }
}

这里有个附带的小问题,我试图在 25edfb22a4f469ecb59f1190150159c6e388a4556c0f65e1904146cc1a846bee94b3e26ee717c64999d7867364b1b4a3bed06894275b65c1ab86501b08a632eb 维护这样的编辑器结构(默认是没有 e388a4556c0f65e1904146cc1a846bee标签的)。效果在 chrome 下运行很好。但是在 safari 中,回车永远不会产生新的 25edfb22a4f469ecb59f1190150159c6标签,这样就是去了该有的列表效果。

插入分割线

调用 document.execCommand('insertHorizontalRule', false, null); 会插入一个f32b48428a809b51f04d3228cdf461fa标签。然而产生的效果却是这样的:


光标和f32b48428a809b51f04d3228cdf461fa的效果一致了。为此要判断当前光标是否在 25edfb22a4f469ecb59f1190150159c6里面,如果是则在 f32b48428a809b51f04d3228cdf461fa后面追加一个空的文本节点 #text 不是的话追加 e388a4556c0f65e1904146cc1a846bee0c6dc11e160d3b678d68754cc175188a94b3e26ee717c64999d7867364b1b4a3。然后将光标定位在里面,可用如下方式查找。

/**
* 查找父元素
* @param {String} root
* @param {String | Array} name
*/
function findParentByTagName(root, name) {
    let parent = root;
    if (typeof name === "string") {
        name = [name];
    }
    while (name.indexOf(parent.nodeName.toLowerCase()) === -1 &&parent.nodeName !== "BODY" && parent.nodeName !== "HTML") {
        parent = parent.parentNode;
    }
    return parent.nodeName === "BODY" || parent.nodeName === "HTML" ?null : parent;
},

插入链接

调用 document.execCommand('createLink', false, url); 方法我们可以插入一个 url 链接,但是该方法不支持插入指定文字的链接。同时对已经有链接的位置可以反复插入新的链接。为此我们需要重写此方法。

function insertLink(url, title) {
    let selection = document.getSelection(),
        range = selection.getRangeAt(0);
    if(range.collapsed) {
        let start = range.startContainer,
            parent = Util.findParentByTagName(start, &#39;a&#39;);
        if(parent) {
            parent.setAttribute(&#39;src&#39;, url);
        }else {
            this.insertHTML(`<a href="${url}">${title}</a>`);
        }
    }else {
        document.execCommand(&#39;createLink&#39;, false, url);
    }
}

设置 h1 ~ h6 标题

浏览器没有现成的方法,但我们可以借助 document.execCommand('formatBlock', false, tag), 来实现,代码如下:

function setHeading(heading) {
    let formatTag = heading,
        formatBlock = document.queryCommandValue("formatBlock");
    if (formatBlock.length > 0 && formatBlock.toLowerCase() === formatTag) {
        document.execCommand(&#39;formatBlock&#39;, false, ``);
    } else {
        document.execCommand(&#39;formatBlock&#39;, false, ``);
    }
}

插入定制内容

当编辑器上传或加载附件的时候,要插入能够展示附件的

节点卡片到编辑中。这里我们借助 document.execCommand('insertHTML', false, html); 来插入内容。为了防止div被编辑,要设置 contenteditable="false"哦。

处理 paste 粘贴

在富文本编辑器中,粘贴效果默认采用如下规则:

如果是带有格式的文本,则保留格式(格式会被转换成html标签的形式)

粘贴图文混排的内容,图片可以显示,src 为图片真实地址。

通过复制图片来进行粘贴的时候,不能粘入内容

粘贴其他格式内容,不能粘入内容

为了能够控制粘贴的内容,我们监听 paste 事件。该事件的 event 对象中会包含一个 clipboardData 剪切板对象。我们可以利用该对象的 getData 方法来获得带有格式和不带格式的内容,如下。

let plainText = event.clipboardData.getData(&#39;text/plain&#39;);  // 无格式文本
let plainHTML = event.clipboardData.getData(&#39;text/html&#39;);   // 有格式文本

之后调用 document.execCommand('insertText', false, plainText); 或 document.execCommand('insertHTML', false, plainHTML; 来重写编辑上的paste效果。

然而对于规则 3 ,上述方案就无法处理了。这里我们要引入 event.clipboardData.items 。这是一个数组包含了所有剪切板中的内容对象。比如你复制了一张图片来粘贴,那么 event.clipboardData.items 的长度就为2:

items[0] 为图片的名称,items[0].kind 为 ‘string’, items[0].type 为 ‘text/plain’ 或 ‘text/html’。获取内容方式如下:

items[0].getAsString(str => {
    // 处理 str 即可
})
items[1] 为图片的二进制数据,items[1].kind 为’file’, items[1].type 为图片的格式。想要获取里面的内容,我们就需要创建 FileReader 对象了。示例代码如下:
let file = items[1].getAsFile();
// file.size 为文件大小
let reader = new FileReader();
reader.onload = function() {
    // reader.result 为文件内容,就可以做上传操作了
}
if(/image/.test(item.type)) {
    reader.readAsDataURL(file);   // 读取为 base64 格式
}

处理完图片,那么对于复制粘贴其他格式内容会怎么样呢?在 mac 中,如果你复制一个磁盘文件,event.clipboardData.items 的长度为 2。 items[0] 依然为文件名,然而 items[1] 则为图片了,没错,是文件的缩略图。

输入法处理

当使用输入发的时候,有时候会发生一些意想不到的事情。 比如百度输入法可以输入一张本地图片,为此我们需要监听输入法产生的内容做处理。这里通过如下两个事件处理:

compositionstart: 当浏览器有非直接的文字输入时, compositionstart事件会以同步模式触发

compositionend: 当浏览器是直接的文字输入时, compositionend会以同步模式触发

修复移动端的问题

在移动端,富文本编辑器的问题主要集中在光标和键盘上面。我这里介绍几个比较大的坑。

自动获取焦点

如果想让我们的编辑器自动获得焦点,弹出软键盘,可以利用 focus() 方法。然而在 ios 下,死活没有结果。这主要是因为 ios safari 中,为了安全考虑不允许代码获得焦点。只能通过用户交互点击才可以。还好,这一限制可以去除:


[self.appWebView setKeyboardDisplayRequiresUserAction:NO]


iOS 下回车换行,滚动条不会自动滚动

在 iOS 下,当我们回车换行的时候,滚动条并不会随着滚动下去。这样光标就可能被键盘挡住,体验不好。为了解决这一问题,我们就需要监听 selectionchange 事件,触发时,计算每次光标编辑器顶端距离,之后再调用 window.scroll() 即可解决。问题在于我们要如何计算当前光标的位置,如果仅是计算光标所在父元素的位置很有可能出现偏差(多行文本计算不准)。我们可以通过创建一个临时 元素查到光标位置,计算元素的位置即可。代码如下:

function getCaretYPosition() {
    let sel = window.getSelection(),
        range = sel.getRangeAt(0);
    let span = document.createElement(&#39;span&#39;);
    range.collapse(false);
    range.insertNode(span);
    var topPosition = span.offsetTop;
    span.parentNode.removeChild(span);
    return topPosition;
}

正当我开心的时候,安卓端反应,编辑器越编辑越卡。什么鬼?我在 chrome 上线检查了一下,发现 selectionchange 函数一直在运行,不管有没有操作。

在逐一排查的时候发现了这么一个事实。range.insertNode 函数同样触发 selectionchange 事件。这样就形成了一个死循环。这个死循环在 safari 中就不会产生,只出现在 safari 中,为此我们就需要加上浏览器类型判断了。

键盘弹起遮挡输入部分

网上对于这个问题主要的方案就是,设置定时器。局限与前端,确实只能这采用这样笨笨的解决。最后我们让 iOS 同学在键盘弹出的时候,将 webview 高度减去软键盘高度就解决了。

CGFloat webviewY = 64.0 + self.noteSourceView.height;
self.appWebView.frame = CGRectMake(0, webviewY, BDScreenWidth,BDScreenHeight - webviewY - height);

插入图片失败

在移动端,通过调用 jsbridge 来唤起相册选择图片。之后调用 insertImage 函数来向编辑器插入图片。然而,插入图片一直失败。最后发现是因为早 safari 下,如果编辑器失去了焦点,那么 selection 和 range 对象将销毁。因此调用 insertImage 时,并不能获得光标所在位置,因此失败。为此需要增加,backupRange() 和 restoreRange() 函数。当页面失去焦点的时候记录 range 信息,插入图片前恢复 range 信息。

backupRange() {
    let selection = window.getSelection();
    let range = selection.getRangeAt(0);
    this.currentSelection = {
        "startContainer": range.startContainer,
        "startOffset": range.startOffset,
        "endContainer": range.endContainer,
        "endOffset": range.endOffset
    }
}
restoreRange() {
    if (this.currentSelection) {
        let selection = window.getSelection();
            selection.removeAllRanges();
        let range = document.createRange();
        range.setStart(this.currentSelection.startContainer,this.currentSelection.startOffset);
        range.setEnd(this.currentSelection.endContainer,this.currentSelection.endOffset);
        // 向选区中添加一个区域
        selection.addRange(range);
    }
}

在 chrome 中,失去焦点并不会清除 seleciton 对象和 range 对象,这样我们轻轻松松一个 focus() 就搞定了。

重要问题就这么多,限于篇幅限制其他的问题省略了。总体来说,填坑花了开发的大部分时间。

其他功能

基础功能修修补补以后,实际项目中有可能遇到一些其他的需求,比如当前光标所在文字内容状态啊,图片拖拽放大啊,待办列表功能,附件卡片等功能啊,markdown切换等等。在了解了js 富文本的种种坑之后,range 对象的操作之后,相信这些问题你都可以轻松解决。这里最后提几个做扩展功能时候遇到的有去的问题。

回车换行带格式

前面已经说过了,富文本编辑器的机制就是这样,当你回车换行的时候新产生的内容和之前的格式一模一样。如果我们利用 .card 类来定义了一个卡片内容,那么换行产生的新的段落都将含有 .card 类且结构也是直接 copy 过来的。我们想要屏蔽这种机制,于是尝试在 keydown 的阶段做处理(如果在 keyup 阶段处理用户体验不好)。然而,并没有什么用,因为用户自定义的 keydown 事件要在 浏览器富文本的默认 keydown 事件之前触发,这样你就做不了任何处理。

为此我们为这类特殊的个体都添加一个 property 属性,添加在 property 上的内容是不会被copy下来的。这样以后就可以区分出来了,从而做对应的处理。

获取当前光标所在处样式

这里主要是考虑 下划线,删除线之类的样式,这些样式都是用标签类描述的,所以要遍历标签层级。直接上代码:

function getCaretStyle() {
    let selection = window.getSelection(),
        range = selection.getRangeAt(0);
        aimEle = range.commonAncestorContainer,
        tempEle = null;
    let tags = ["U", "I", "B", "STRIKE"],
        result = [];
    if(aimEle.nodeType === 3) {
        aimEle = aimEle.parentNode;
    }
    tempEle = aimEle;
    while(block.indexOf(tempEle.nodeName.toLowerCase()) === -1) {
        if(tags.indexOf(tempEle.nodeName) !== -1) {
            result.push(tempEle.nodeName);
        }
        tempEle = tempEle.parentNode;
    }
    let viewStyle = {
        "italic": result.indexOf("I") !== -1 ? true : false,
        "underline": result.indexOf("U") !== -1 ? true : false,
        "bold": result.indexOf("B") !== -1 ? true : false,
        "strike": result.indexOf("STRIKE") !== -1 ? true : false
    }
    let styles = window.getComputedStyle(aimEle, null);
    viewStyle.fontSize = styles["fontSize"],
    viewStyle.color = styles["color"],
    viewStyle.fontWeight = styles["fontWeight"],
    viewStyle.fontStyle = styles["fontStyle"],
    viewStyle.textDecoration = styles["textDecoration"];
    viewStyle.isH1 = Util.findParentByTagName(aimEle, "h1") ? true : false;
    viewStyle.isH2 = Util.findParentByTagName(aimEle, "h2") ? true : false;
    viewStyle.isP = Util.findParentByTagName(aimEle, "p") ? true : false;
    viewStyle.isUl = Util.findParentByTagName(aimEle, "ul") ? true : false;
    viewStyle.isOl = Util.findParentByTagName(aimEle, "ol") ? true : false;
    return viewStyle;
}

以上就是我做富文本编辑器的思路,也希望给大家带来帮助。

위 내용은 자바스크립트를 사용하여 서식 있는 텍스트 편집기 구현의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.