引子


富文本編輯器的核心:contentEditable 和 document.execCommand
當(dāng)一個(gè)HTML元素的
contenteditable屬性被設(shè)置為true時(shí),document.execCommand()方法便可使用。通過(guò)該方法,你可以運(yùn)行相關(guān)commands 來(lái)操作可編輯區(qū)域的內(nèi)容。
<!doctype html>
<html>
<head>
<title>Rich Text Editor</title>
<script type="text/javascript">
var oDoc, sDefTxt;
function initDoc() {
oDoc = document.getElementById("textBox");
sDefTxt = oDoc.innerHTML;
if (document.compForm.switchMode.checked) { setDocMode(true); }
}
function formatDoc(sCmd, sValue) {
if (validateMode()) { document.execCommand(sCmd, false, sValue); oDoc.focus(); }
}
function validateMode() {
if (!document.compForm.switchMode.checked) { return true ; }
alert("Uncheck \"Show HTML\".");
oDoc.focus();
return false;
}
function setDocMode(bToSource) {
var oContent;
if (bToSource) {
oContent = document.createTextNode(oDoc.innerHTML);
oDoc.innerHTML = "";
var oPre = document.createElement("pre");
oDoc.contentEditable = false;
oPre.id = "sourceText";
oPre.contentEditable = true;
oPre.appendChild(oContent);
oDoc.appendChild(oPre);
document.execCommand("defaultParagraphSeparator", false, "div");
} else {
if (document.all) {
oDoc.innerHTML = oDoc.innerText;
} else {
oContent = document.createRange();
oContent.selectNodeContents(oDoc.firstChild);
oDoc.innerHTML = oContent.toString();
}
oDoc.contentEditable = true;
}
oDoc.focus();
}
function printDoc() {
if (!validateMode()) { return; }
var oPrntWin = window.open("","_blank","width=450,height=470,left=400,top=100,menubar=yes,toolbar=no,location=no,scrollbars=yes");
oPrntWin.document.open();
oPrntWin.document.write("<!doctype html><html><head><title>Print<\/title><\/head><body onload=\"print();\">" + oDoc.innerHTML + "<\/body><\/html>");
oPrntWin.document.close();
}
</script>
<style type="text/css">
.intLink { cursor: pointer; }
img.intLink { border: 0; }
#toolBar1 select { font-size:10px; }
#textBox {
width: 540px;
height: 200px;
border: 1px #000000 solid;
padding: 12px;
overflow: scroll;
}
#textBox #sourceText {
padding: 0;
margin: 0;
min-width: 498px;
min-height: 200px;
}
#editMode label { cursor: pointer; }
</style>
</head>
<body onload="initDoc();">
<form name="compForm" method="post" action="sample.php" onsubmit="if(validateMode()){this.myDoc.value=oDoc.innerHTML;return true;}return false;">
<input type="hidden" name="myDoc">
<div id="toolBar1">
<select onchange="formatDoc('formatblock',this[this.selectedIndex].value);this.selectedIndex=0;">
<option selected>- formatting -</option>
<option value="h1">Title 1 <h1></option>
<option value="h2">Title 2 <h2></option>
<option value="h3">Title 3 <h3></option>
<option value="h4">Title 4 <h4></option>
<option value="h5">Title 5 <h5></option>
<option value="h6">Subtitle <h6></option>
<option value="p">Paragraph <p></option>
<option value="pre">Preformatted <pre></option>
</select>
<select onchange="formatDoc('fontname',this[this.selectedIndex].value);this.selectedIndex=0;">
<option class="heading" selected>- font -</option>
<option>Arial</option>
<option>Arial Black</option>
<option>Courier New</option>
<option>Times New Roman</option>
</select>
<select onchange="formatDoc('fontsize',this[this.selectedIndex].value);this.selectedIndex=0;">
<option class="heading" selected>- size -</option>
<option value="1">Very small</option>
<option value="2">A bit small</option>
<option value="3">Normal</option>
<option value="4">Medium-large</option>
<option value="5">Big</option>
<option value="6">Very big</option>
<option value="7">Maximum</option>
</select>
<select onchange="formatDoc('forecolor',this[this.selectedIndex].value);this.selectedIndex=0;">
<option class="heading" selected>- color -</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="black">Black</option>
</select>
<select onchange="formatDoc('backcolor',this[this.selectedIndex].value);this.selectedIndex=0;">
<option class="heading" selected>- background -</option>
<option value="red">Red</option>
<option value="green">Green</option>
<option value="black">Black</option>
</select>
</div>
<div id="toolBar2">
<img class="intLink" title="Clean" onclick="if(validateMode()&&confirm('Are you sure?')){oDoc.innerHTML=sDefTxt};" src="data:image/gif;base64,R0lGODlhFgAWAIQbAD04KTRLYzFRjlldZl9vj1dusY14WYODhpWIbbSVFY6O7IOXw5qbms+wUbCztca0ccS4kdDQjdTLtMrL1O3YitHa7OPcsd/f4PfvrvDv8Pv5xv///////////////////yH5BAEKAB8ALAAAAAAWABYAAAV84CeOZGmeaKqubMteyzK547QoBcFWTm/jgsHq4rhMLoxFIehQQSAWR+Z4IAyaJ0kEgtFoLIzLwRE4oCQWrxoTOTAIhMCZ0tVgMBQKZHAYyFEWEV14eQ8IflhnEHmFDQkAiSkQCI2PDC4QBg+OAJc0ewadNCOgo6anqKkoIQA7" />
<img class="intLink" title="Print" onclick="printDoc();" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAABGdBTUEAALGPC/xhBQAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9oEBxcZFmGboiwAAAAIdEVYdENvbW1lbnQA9syWvwAAAuFJREFUOMvtlUtsjFEUx//n3nn0YdpBh1abRpt4LFqtqkc3jRKkNEIsiIRIBBEhJJpKlIVo4m1RRMKKjQiRMJRUqUdKPT71qpIpiRKPaqdF55tv5vvusZjQTjOlseUkd3Xu/3dPzusC/22wtu2wRn+jG5So/OCDh8ycMJDflehMlkJkVK7KUYN+ufzA/RttH76zaVocDptRxzQtNi3mRWuPc+6cKtlXZ/sddP2uu9uXlmYXZ6Qm8v4Tz8lhF1H+zDQXt7S8oLMXtbF4e8QaFHjj3kbP2MzkktHpiTjp9VH6iHiA+whtAsX5brpwueMGdONdf/2A4M7ukDs1JW662+XkqTkeUoqjKtOjm2h53YFL15pSJ04Zc94wdtibr26fXlC2mzRvBccEbz2kiRFD414tKMlEZbVGT33+qCoHgha81SWYsew0r1uzfNylmtpx80pngQQ91LwVk2JGvGnfvZG6YcYRAT16GFtW5kKKfo1EQLtfh5Q2etT0BIWF+aitq4fDbk+ImYo1OxvGF03waFJQvBCkvDffRyEtxQiFFYgAZTHS0zwAGD7fG5TNnYNTp8/FzvGwJOfmgG7GOx0SAKKgQgDMgKBI0NJGMEImpGDk5+WACEwEd0ywblhGUZ4Hw5OdUekRBLT7DTgdEgxACsIznx8zpmWh7k4rkpJcuHDxCul6MDsmmBXDlWCH2+XozSgBnzsNCEE4euYV4pwCpsWYPW0UHDYBKSWu1NYjENDReqtKjwn2+zvtTc1vMSTB/mvev/WEYSlASsLimcOhOBJxw+N3aP/SjefNL5GePZmpu4kG7OPr1+tOfPyUu3BecWYKcwQcDFmwFKAUo90fhKDInBCAmvqnyMgqUEagQwCoHBDc1rjv9pIlD8IbVkz6qYViIBQGTJPx4k0XpIgEZoRN1Da0cij4VfR0ta3WvBXH/rjdCufv6R2zPgPH/e4pxSBCpeatqPrjNiso203/5s/zA171Mv8+w1LOAAAAAElFTkSuQmCC">
<img class="intLink" title="Undo" onclick="formatDoc('undo');" src="data:image/gif;base64,R0lGODlhFgAWAOMKADljwliE33mOrpGjuYKl8aezxqPD+7/I19DV3NHa7P///////////////////////yH5BAEKAA8ALAAAAAAWABYAAARR8MlJq7046807TkaYeJJBnES4EeUJvIGapWYAC0CsocQ7SDlWJkAkCA6ToMYWIARGQF3mRQVIEjkkSVLIbSfEwhdRIH4fh/DZMICe3/C4nBQBADs=" />
<img class="intLink" title="Redo" onclick="formatDoc('redo');" src="data:image/gif;base64,R0lGODlhFgAWAMIHAB1ChDljwl9vj1iE34Kl8aPD+7/I1////yH5BAEKAAcALAAAAAAWABYAAANKeLrc/jDKSesyphi7SiEgsVXZEATDICqBVJjpqWZt9NaEDNbQK1wCQsxlYnxMAImhyDoFAElJasRRvAZVRqqQXUy7Cgx4TC6bswkAOw==" />
<img class="intLink" title="Remove formatting" onclick="formatDoc('removeFormat')" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAABGdBTUEAALGPC/xhBQAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAAd0SU1FB9oECQMCKPI8CIIAAAAIdEVYdENvbW1lbnQA9syWvwAAAuhJREFUOMtjYBgFxAB501ZWBvVaL2nHnlmk6mXCJbF69zU+Hz/9fB5O1lx+bg45qhl8/fYr5it3XrP/YWTUvvvk3VeqGXz70TvbJy8+Wv39+2/Hz19/mGwjZzuTYjALuoBv9jImaXHeyD3H7kU8fPj2ICML8z92dlbtMzdeiG3fco7J08foH1kurkm3E9iw54YvKwuTuom+LPt/BgbWf3//sf37/1/c02cCG1lB8f//f95DZx74MTMzshhoSm6szrQ/a6Ir/Z2RkfEjBxuLYFpDiDi6Af///2ckaHBp7+7wmavP5n76+P2ClrLIYl8H9W36auJCbCxM4szMTJac7Kza////R3H1w2cfWAgafPbqs5g7D95++/P1B4+ECK8tAwMDw/1H7159+/7r7ZcvPz4fOHbzEwMDwx8GBgaGnNatfHZx8zqrJ+4VJBh5CQEGOySEua/v3n7hXmqI8WUGBgYGL3vVG7fuPK3i5GD9/fja7ZsMDAzMG/Ze52mZeSj4yu1XEq/ff7W5dvfVAS1lsXc4Db7z8C3r8p7Qjf///2dnZGxlqJuyr3rPqQd/Hhyu7oSpYWScylDQsd3kzvnH738wMDzj5GBN1VIWW4c3KDon7VOvm7S3paB9u5qsU5/x5KUnlY+eexQbkLNsErK61+++VnAJcfkyMTIwffj0QwZbJDKjcETs1Y8evyd48toz8y/ffzv//vPP4veffxpX77z6l5JewHPu8MqTDAwMDLzyrjb/mZm0JcT5Lj+89+Ybm6zz95oMh7s4XbygN3Sluq4Mj5K8iKMgP4f0////fv77//8nLy+7MCcXmyYDAwODS9jM9tcvPypd35pne3ljdjvj26+H2dhYpuENikgfvQeXNmSl3tqepxXsqhXPyc666s+fv1fMdKR3TK72zpix8nTc7bdfhfkEeVbC9KhbK/9iYWHiErbu6MWbY/7//8/4//9/pgOnH6jGVazvFDRtq2VgiBIZrUTIBgCk+ivHvuEKwAAAAABJRU5ErkJggg==">
<img class="intLink" title="Bold" onclick="formatDoc('bold');" src="data:image/gif;base64,R0lGODlhFgAWAID/AMDAwAAAACH5BAEAAAAALAAAAAAWABYAQAInhI+pa+H9mJy0LhdgtrxzDG5WGFVk6aXqyk6Y9kXvKKNuLbb6zgMFADs=" />
<img class="intLink" title="Italic" onclick="formatDoc('italic');" src="data:image/gif;base64,R0lGODlhFgAWAKEDAAAAAF9vj5WIbf///yH5BAEAAAMALAAAAAAWABYAAAIjnI+py+0Po5x0gXvruEKHrF2BB1YiCWgbMFIYpsbyTNd2UwAAOw==" />
<img class="intLink" title="Underline" onclick="formatDoc('underline');" src="data:image/gif;base64,R0lGODlhFgAWAKECAAAAAF9vj////////yH5BAEAAAIALAAAAAAWABYAAAIrlI+py+0Po5zUgAsEzvEeL4Ea15EiJJ5PSqJmuwKBEKgxVuXWtun+DwxCCgA7" />
<img class="intLink" title="Left align" onclick="formatDoc('justifyleft');" src="data:image/gif;base64,R0lGODlhFgAWAID/AMDAwAAAACH5BAEAAAAALAAAAAAWABYAQAIghI+py+0Po5y02ouz3jL4D4JMGELkGYxo+qzl4nKyXAAAOw==" />
<img class="intLink" title="Center align" onclick="formatDoc('justifycenter');" src="data:image/gif;base64,R0lGODlhFgAWAID/AMDAwAAAACH5BAEAAAAALAAAAAAWABYAQAIfhI+py+0Po5y02ouz3jL4D4JOGI7kaZ5Bqn4sycVbAQA7" />
<img class="intLink" title="Right align" onclick="formatDoc('justifyright');" src="data:image/gif;base64,R0lGODlhFgAWAID/AMDAwAAAACH5BAEAAAAALAAAAAAWABYAQAIghI+py+0Po5y02ouz3jL4D4JQGDLkGYxouqzl43JyVgAAOw==" />
<img class="intLink" title="Numbered list" onclick="formatDoc('insertorderedlist');" src="data:image/gif;base64,R0lGODlhFgAWAMIGAAAAADljwliE35GjuaezxtHa7P///////yH5BAEAAAcALAAAAAAWABYAAAM2eLrc/jDKSespwjoRFvggCBUBoTFBeq6QIAysQnRHaEOzyaZ07Lu9lUBnC0UGQU1K52s6n5oEADs=" />
<img class="intLink" title="Dotted list" onclick="formatDoc('insertunorderedlist');" src="data:image/gif;base64,R0lGODlhFgAWAMIGAAAAAB1ChF9vj1iE33mOrqezxv///////yH5BAEAAAcALAAAAAAWABYAAAMyeLrc/jDKSesppNhGRlBAKIZRERBbqm6YtnbfMY7lud64UwiuKnigGQliQuWOyKQykgAAOw==" />
<img class="intLink" title="Quote" onclick="formatDoc('formatblock','blockquote');" src="data:image/gif;base64,R0lGODlhFgAWAIQXAC1NqjFRjkBgmT9nqUJnsk9xrFJ7u2R9qmKBt1iGzHmOrm6Sz4OXw3Odz4Cl2ZSnw6KxyqO306K63bG70bTB0rDI3bvI4P///////////////////////////////////yH5BAEKAB8ALAAAAAAWABYAAAVP4CeOZGmeaKqubEs2CekkErvEI1zZuOgYFlakECEZFi0GgTGKEBATFmJAVXweVOoKEQgABB9IQDCmrLpjETrQQlhHjINrTq/b7/i8fp8PAQA7" />
<img class="intLink" title="Delete indentation" onclick="formatDoc('outdent');" src="data:image/gif;base64,R0lGODlhFgAWAMIHAAAAADljwliE35GjuaezxtDV3NHa7P///yH5BAEAAAcALAAAAAAWABYAAAM2eLrc/jDKCQG9F2i7u8agQgyK1z2EIBil+TWqEMxhMczsYVJ3e4ahk+sFnAgtxSQDqWw6n5cEADs=" />
<img class="intLink" title="Add indentation" onclick="formatDoc('indent');" src="data:image/gif;base64,R0lGODlhFgAWAOMIAAAAADljwl9vj1iE35GjuaezxtDV3NHa7P///////////////////////////////yH5BAEAAAgALAAAAAAWABYAAAQ7EMlJq704650B/x8gemMpgugwHJNZXodKsO5oqUOgo5KhBwWESyMQsCRDHu9VOyk5TM9zSpFSr9gsJwIAOw==" />
<img class="intLink" title="Hyperlink" onclick="var sLnk=prompt('Write the URL here','http:\/\/');if(sLnk&&sLnk!=''&&sLnk!='http://'){formatDoc('createlink',sLnk)}" src="data:image/gif;base64,R0lGODlhFgAWAOMKAB1ChDRLY19vj3mOrpGjuaezxrCztb/I19Ha7Pv8/f///////////////////////yH5BAEKAA8ALAAAAAAWABYAAARY8MlJq7046827/2BYIQVhHg9pEgVGIklyDEUBy/RlE4FQF4dCj2AQXAiJQDCWQCAEBwIioEMQBgSAFhDAGghGi9XgHAhMNoSZgJkJei33UESv2+/4vD4TAQA7" />
<img class="intLink" title="Cut" onclick="formatDoc('cut');" src="data:image/gif;base64,R0lGODlhFgAWAIQSAB1ChBFNsRJTySJYwjljwkxwl19vj1dusYODhl6MnHmOrpqbmpGjuaezxrCztcDCxL/I18rL1P///////////////////////////////////////////////////////yH5BAEAAB8ALAAAAAAWABYAAAVu4CeOZGmeaKqubDs6TNnEbGNApNG0kbGMi5trwcA9GArXh+FAfBAw5UexUDAQESkRsfhJPwaH4YsEGAAJGisRGAQY7UCC9ZAXBB+74LGCRxIEHwAHdWooDgGJcwpxDisQBQRjIgkDCVlfmZqbmiEAOw==" />
<img class="intLink" title="Copy" onclick="formatDoc('copy');" src="data:image/gif;base64,R0lGODlhFgAWAIQcAB1ChBFNsTRLYyJYwjljwl9vj1iE31iGzF6MnHWX9HOdz5GjuYCl2YKl8ZOt4qezxqK63aK/9KPD+7DI3b/I17LM/MrL1MLY9NHa7OPs++bx/Pv8/f///////////////yH5BAEAAB8ALAAAAAAWABYAAAWG4CeOZGmeaKqubOum1SQ/kPVOW749BeVSus2CgrCxHptLBbOQxCSNCCaF1GUqwQbBd0JGJAyGJJiobE+LnCaDcXAaEoxhQACgNw0FQx9kP+wmaRgYFBQNeAoGihCAJQsCkJAKOhgXEw8BLQYciooHf5o7EA+kC40qBKkAAAGrpy+wsbKzIiEAOw==" />
<img class="intLink" title="Paste" onclick="formatDoc('paste');" src="data:image/gif;base64,R0lGODlhFgAWAIQUAD04KTRLY2tXQF9vj414WZWIbXmOrpqbmpGjudClFaezxsa0cb/I1+3YitHa7PrkIPHvbuPs+/fvrvv8/f///////////////////////////////////////////////yH5BAEAAB8ALAAAAAAWABYAAAWN4CeOZGmeaKqubGsusPvBSyFJjVDs6nJLB0khR4AkBCmfsCGBQAoCwjF5gwquVykSFbwZE+AwIBV0GhFog2EwIDchjwRiQo9E2Fx4XD5R+B0DDAEnBXBhBhN2DgwDAQFjJYVhCQYRfgoIDGiQJAWTCQMRiwwMfgicnVcAAAMOaK+bLAOrtLUyt7i5uiUhADs=" />
</div>
<div id="textBox" contenteditable="true"><p>Lorem ipsum</p></div>
<p id="editMode"><input type="checkbox" name="switchMode" id="switchBox" onchange="setDocMode(this.checked);" /> <label for="switchBox">Show HTML</label></p>
<p><input type="submit" value="Send" /></p>
</form>
</body>
</html>
Let's go!
L0階段
遠(yuǎn)古時(shí)代的前端開(kāi)發(fā)者應(yīng)該會(huì)對(duì)這樣的圖有那么一絲絲印象(暴露年齡了)(那時(shí)候還沒(méi)有什么UI可言

沒(méi)錯(cuò),圖上就是從前很有名的UEditor(百度出品的開(kāi)源富文本編輯器),同期同類(lèi)型編輯器還有CKEditor 1-4(遙遠(yuǎn)的2008年)。
我們來(lái)回顧下L0階段的富文本編輯器是怎樣實(shí)現(xiàn)的:
- 用
contentEditable實(shí)現(xiàn)內(nèi)容的可編輯。 - 基于
document.execCommand和自定義擴(kuò)展的execCommand去去操作DOM實(shí)現(xiàn)富文內(nèi)容的修改。 - 輔以一些DOM的嵌套規(guī)則(dtd)和復(fù)雜數(shù)據(jù)輸入(如粘貼)的過(guò)濾規(guī)則來(lái)約束數(shù)據(jù)的正確性。
- 輸出富文本內(nèi)容是HTML字符串。
簡(jiǎn)單點(diǎn)來(lái)說(shuō)就是contentEditable和document.execCommand我全都要,可以簡(jiǎn)單理解為引言例子的擴(kuò)展。
L0的問(wèn)題:
L0的問(wèn)題很有代表性
- 兼容問(wèn)題:不同瀏覽器的表現(xiàn)不同
- 拖拽、復(fù)制黏貼、刪除等不可控的交互帶來(lái)的數(shù)據(jù)混亂問(wèn)題
- 對(duì)協(xié)同編輯器支持很困難
- 定制空間有限
L1 階段
Quill.js 2012
- 依賴(lài)DOM的
contentEditable特性 - 不直接操作DOM,而是通過(guò)模型API
- 對(duì)DOM TREE 和模型操作進(jìn)行抽象
- Delta來(lái)描述編輯器的內(nèi)容及其變化(協(xié)同編輯有路子了)
"ops": [ { "insert": "Hello " }, { "attributes": { "bold": true }, "insert": "Quill" }, { "insert": "!" } ]
} ```
本質(zhì)是OT模型的一種實(shí)現(xiàn)
OT(Operation Transformation),操作轉(zhuǎn)換,協(xié)同技術(shù)中用來(lái)保持不同的數(shù)據(jù)副本一致性的一種方法。在不同的終端,根據(jù)操作順序的不同,對(duì)操作進(jìn)行調(diào)整,以保持?jǐn)?shù)據(jù)一致性.
石墨文檔的富文本是基于Quill開(kāi)發(fā)的。
ProseMirror 2015
- 依賴(lài)DOM的
contentEditable特性 - JSON數(shù)據(jù)描述富文本內(nèi)容
- 引入不可變數(shù)據(jù)(統(tǒng)一了內(nèi)容的修改,把原來(lái)的更改DOM的方式改為對(duì)不可變數(shù)據(jù)的修改)以及Virtual DOM的概念(前端架構(gòu)的feel來(lái)了)
- 輸出是純JSON
很有代表行的L1階段編輯器:代理了瀏覽器大部分的默認(rèn)行為,把操作轉(zhuǎn)換為數(shù)據(jù)的變換,進(jìn)而更新UI。
Draft.js
- React 作為UI層
- 主流編程思想 使用狀態(tài)管理的思想管理富文本數(shù)據(jù)
知乎的富文本編輯器基于Draft.js開(kāi)發(fā)。
Slate
- 目前仍然是beta版本
- 大量借鑒前者優(yōu)點(diǎn)
- 插件是一等公民,可以完全 定制編輯體驗(yàn),去建立像 Medium 或是 Dropbox 這樣復(fù)雜的編輯器,而不必對(duì)各種類(lèi)庫(kù)進(jìn)行猜測(cè)。
- 嵌套文檔模型,可協(xié)作的數(shù)據(jù)模型。
非開(kāi)箱即用,適用于構(gòu)建自己的編輯器。
L1的特點(diǎn)總結(jié):
- 基于
contentEditable特性 - 對(duì)富文本內(nèi)容有了一定的抽象
- 引入了主流的編程思想
問(wèn)題: 仍然依賴(lài)瀏覽器的可編輯能力
L2 編輯器的未來(lái)
不依賴(lài)任何瀏覽器的編輯功能,自主實(shí)現(xiàn)光標(biāo)、選區(qū)、排版。
Google Docs
Office Word Online
iCloud Pages
WPS 在線(xiàn)版
無(wú)開(kāi)源項(xiàng)目
總結(jié)
從L0-L1-L2,我們可以看到從完全依賴(lài)瀏覽器的編輯功能,到部分依賴(lài)編輯功能,再到完全拋棄瀏覽器的編輯功能,這中間是日益豐富的編輯器需求在推動(dòng)技術(shù)的進(jìn)步。也可以看到前端技術(shù)變化的影子。
大廠(chǎng)是怎么做的
聊到這里,肯定有人會(huì)問(wèn),語(yǔ)雀用的哪款編輯器?怎么上面都沒(méi)有看到?
我在對(duì)編輯器選型的時(shí)候,第一反應(yīng)就是直接對(duì)標(biāo)語(yǔ)雀,功能全,使用感也很好。
然而結(jié)果是,語(yǔ)雀富文本編輯器是自主開(kāi)發(fā)的并且不會(huì)開(kāi)源。。。不過(guò)也有一些分享一些編輯器開(kāi)發(fā)的經(jīng)驗(yàn)。
原型階段:CodeMirror(L0)+React+antd——優(yōu)雅的markdown(應(yīng)用層服務(wù)器用了Egg) 在線(xiàn)編輯器

內(nèi)部服務(wù)階段:Slate(L1)+React+antd——正式的富文本編輯器

商業(yè)化階段:自研編輯器(Lake Editor)+React——現(xiàn)在我們用的語(yǔ)雀

內(nèi)部服務(wù)階段到商業(yè)化階段的轉(zhuǎn)型:
開(kāi)始是基于slate開(kāi)發(fā)的,但是后面發(fā)現(xiàn)了很多問(wèn)題,例如難以修復(fù),頁(yè)面崩潰,光標(biāo)錯(cuò)亂,粘貼卡死等等。還有很多個(gè)性化需求實(shí)現(xiàn)成本高。于是后面就轉(zhuǎn)為重寫(xiě)L1編輯器。
(我個(gè)人理解語(yǔ)雀遇到的問(wèn)題其實(shí)所有使用開(kāi)源富文本編輯器的都會(huì)遇到,畢竟隨便打開(kāi)一個(gè)star有幾K的還在頻繁更新的編輯器都能看到成百上千的issues(open狀態(tài)))。
- 數(shù)據(jù)格式:在HTML基礎(chǔ)上擴(kuò)展
- 卡片機(jī)制:承接組件的擴(kuò)展,在編輯器里獨(dú)立的一塊區(qū)域
- 開(kāi)發(fā)模式:Hybrid混合開(kāi)發(fā),編輯區(qū)域用原生JS,UI層用React
- 技術(shù)原理:基于
contentEditable,通過(guò)Range API 對(duì)選中的內(nèi)容進(jìn)行操作 -
通過(guò) canvas 實(shí)現(xiàn)了表格編輯器,通過(guò) SVG 實(shí)現(xiàn)了思維導(dǎo)圖編輯器
語(yǔ)雀4.png
多人實(shí)時(shí)協(xié)同:
市面產(chǎn)品:Google Docs CKEditor 5 Slate Quill 都用了OT(Operation Transformation)或者類(lèi)似的技術(shù),將操作轉(zhuǎn)化為OP,發(fā)送到協(xié)作服務(wù),再轉(zhuǎn)發(fā)給其他在線(xiàn)用戶(hù)。
最后的實(shí)現(xiàn):
OT服務(wù):基于ShareDB
數(shù)據(jù)格式:JSONML
技術(shù)原理:通過(guò)MutationOBserver API 監(jiān)聽(tīng)編輯器的DOM樹(shù)變更,生成JSON格式的OP,發(fā)送到ShareDB,更新JSONML數(shù)據(jù)。同時(shí)將OP發(fā)送到其他用戶(hù),將OP轉(zhuǎn)化成DOM操作方法之后執(zhí)行。
最后語(yǔ)雀是JavaScript 全棧進(jìn)行研發(fā)的,感興趣的同學(xué)可以深入研究最后的附文。
聊聊實(shí)操
說(shuō)了這么多,我們自己開(kāi)發(fā)項(xiàng)目的時(shí)候怎么選呢。

但是我自己的經(jīng)驗(yàn)是:普通的業(yè)務(wù)需求,其實(shí)需要我們提供一個(gè)稍微美觀又開(kāi)箱即用的富文本編輯器即可。只要滿(mǎn)足需求即可。二次開(kāi)發(fā)越少越好。還有一點(diǎn)就是要看下最近的維護(hù)時(shí)間。
https://github.com/topics/wysiwyg-editor
這個(gè)話(huà)題下面有很多可選的編輯器,大家可以找自己用的順手的。
目前正在開(kāi)發(fā)的CCTASK知乎庫(kù),暫時(shí)選擇了CKEditor5,之前是看中他功能比較全,并且支持在線(xiàn)多人協(xié)同,但是現(xiàn)在發(fā)現(xiàn)協(xié)同功能需要CKCloud云服務(wù)支持。
使用的時(shí)候,可以用在線(xiàn)工具配置需要的插件,再引入項(xiàng)目,進(jìn)行菜單欄的配置。
圖片上傳功能需要寫(xiě)一個(gè)MyUploadAdapter導(dǎo)出一個(gè)插件再進(jìn)行引用。
目前遇到的問(wèn)題只是文檔資源較少,后面還需要時(shí)間的驗(yàn)證。

附錄:參考文章
https://zhuanlan.zhihu.com/p/268366406
https://max.book118.com/html/2019/1027/8004023067002060.shtm
https://www.yuque.com/seeconf/2020/dn74yy
https://www.yuque.com/preview/yuque/0/2020/pdf/84135/1578380717315-17b401af-78d5-4eaf-a4ed-b3a93ccb91d2.pdf
https://ckeditor.com/docs/index.html
