浏览器中的HTML富文本编辑(二)

2009-09-27 09:48 | Army

http://www.army8735.org/2009/09/27/156.html

翻译的第二篇,服务广大人民群众。这里贴不出来格式。

---

原文标题:《Rich HTML editing in the browser: part 2》

原文地址:http://dev.opera.com/articles/view/rich-html-editing-in-the-browser-part-2/
介绍

在本系列文章的第一篇中,我详细阐述了如何利用javascript语言在设计模式(designMode)和可编辑内容(contentEditable)中创建html富文本编辑器的理论知识。这些文档对象模型(DOM)已经成为HTML5标准的一部分,并且现代主流浏览器也陆续地开始支持。本篇文章作为第二部分,我将化理论为实践,带你走进制作一款简单而跨浏览器的在线文本编辑器的世界。
你可以在这里看到在线完成的版本,点这里能下载它的源码。下面列出的将是代码中最重要的部分,我们将详细地来叙说它,其余乏味的地方就被省略了。
所有代码被分割为3个文件:

* editor.js:主要的应用程序结构
* editlib.js:一个修改选区的方法集合
* util.js:一些有用的方法

框架

我们将使用一个空白的内嵌(IFrame)页面作为画布:

html 代码
关于

1. <iframe id="editorFrame" src="blank.html"></iframe>

<iframe id="editorFrame" src="blank.html"></iframe>

我们可能会这样用:清除源文件中的代码从而得到一个body里完全没有任何元素的空白页,但是我更倾向于创造一个自定义“空白页”,里面放入一个空的段落,就像:

html 代码
关于

1. <title></title>
2. <body><p></p></body>

<title></title>
<body><p></p></body>

这样做自然有它可取之处,因为使用p元素作为开始来包含内容的话,Mozilla便能够和其它浏览器兼容了。(倘若不这样做,Mozilla将直接进入body元素的内容区。)使用可编辑内容属性(contentEditable attribute),我们能够避免使用框架而直接在页面上创建一个可编辑的div区域,但是Firefox 2并不支持,所以为了兼容性最好还是以内嵌页面(IFrame)为基础来制作跨浏览器的编辑器。
激活编辑模式

当页面加载完成时,我们使用下面的方法来激活编辑模式(editor.js中)。

js 代码
关于

1. function createEditor() {
2. var editFrame = document.getElementById("editorFrame");
3. editFrame.contentWindow.document.designMode="on";
4. }
5. bindEvent(window, "load", createEditor);

function createEditor() {
var editFrame = document.getElementById("editorFrame");
editFrame.contentWindow.document.designMode="on";
}
bindEvent(window, "load", createEditor);

bindEvent是个很有用的方法(在util.js中定义),用来绑定事件响应的方法。JQuery框架中也有类似的绑定方法,你可能很喜欢这样用。
下一步是创建一个具有格式化文本功能的工具栏(toolbar)。
工具栏

我们先从简单的开始:创建一个“粗体(bold)”按钮,它能将当前选区的文字变粗。当然我们也希望这个按钮能够跟踪文档的状态——当插入点或者选区在粗体文本上时,它能够高亮显示。
主要逻辑被分为两块:一是创建一个命令对象(command object),用以包装当前文档上的实际操作,当然也能查询选区的状态;二是创建一个控制器对象(controller object),能够作为句柄(handler)绑定到点击事件(click event)上,同时更新html按钮的外观。这样分开来比较合乎情理,因为不同的命令拥有相似的控制逻辑。关于这一点我们晚些就会看到。
事件流有两个方向——当工具栏上的控制按钮被点击后,控制器告诉命令在文档上执行;而当光标在文档上移动时,我们想要工具栏上的按钮能及时更新状态。我们追踪所有的控制器,当选区被修改时,查询命令获得其状态并更新相应的按钮外观。
命令和控制器的实现

在粗体命令实现之前,命令对象之上只做了个很小的包装:

js 代码
关于

1. function Command(command, editDoc) {
2. this.execute = function() {
3. editDoc.execCommand(command, false, null);
4. };
5. this.queryState = function() {
6. return editDoc.queryCommandState(command)
7. };
8. }

function Command(command, editDoc) {
this.execute = function() {
editDoc.execCommand(command, false, null);
};
this.queryState = function() {
return editDoc.queryCommandState(command)
};
}

为什么要进行包装呢?因为我们希望能够像内建的命令一样,让自定义命令拥有统一的接口。

实际上按钮仅仅是一个span元素。

html 代码
关于

1. <span id="boldButton">Bold</span>

<span id="boldButton">Bold</span>

这个span元素通过控制器(controller)和命令对象(command object)关联。

js 代码
关于

1. function TogglCommandController(command, elem) {
2. this.updateUI = function() {
3. var state = command.queryState();
4. elem.className = state?"active":"";
5. }
6. bindEvent(elem, "click", function(evt) {
7. command.execute();
8. updateToolbar();
9. });
10. }

function TogglCommandController(command, elem) {
this.updateUI = function() {
var state = command.queryState();
elem.className = state?"active":"";
}
bindEvent(elem, "click", function(evt) {
command.execute();
updateToolbar();
});
}

列出来的代码中忽略了一些附加代码,用以确保按下按钮后,窗口(window)仍然在聚焦(focus)状态中。
我们将上面那个方法命名为TogglCommandController,是因为它利用两种不同状态将将一个双状态命令(two-state commands)连接到一个按钮上。当点击按钮时,命令就被运行了。而后updateUI命令被调用,span元素上添加active样式类(class),按钮从而改变其外观。下面是为每种按钮所定义的不同样式:

css 代码
关于

1. .toolbar span {
2. border: outset;
3. }
4.
5. .toolbar span.active {
6. border: inset;
7. }

.toolbar span {
border: outset;
}

.toolbar span.active {
border: inset;
}

组件是如此链接的:

js 代码
关于

1. var command = Command("Bold", editDoc);
2. var elem = document.getElementById(?boldButton);
3. var controller = new TogglCommandController(command, elem);
4. updateListeners.push(controller);

var command = Command("Bold", editDoc);
var elem = document.getElementById(?boldButton);
var controller = new TogglCommandController(command, elem);
updateListeners.push(controller);

updateListeners收集器为工具栏提供控制。updateToolbar方法通过迭代列表并且调用每个控制器上的updateUI方法来确保所有控制按钮都被更新了。为了确保在文档的选区范围发生改变之后updateToolbar能够及时运行,我们绑定了如下事件:

js 代码
关于

1. bindEvent(editDoc, "keyup", updateToolbar);
2. bindEvent(editDoc, "mouseup", updateToolbar);

bindEvent(editDoc, "keyup", updateToolbar);
bindEvent(editDoc, "mouseup", updateToolbar);

当一个命令执行时,updateToolbar也会被调用,如同上面列出的命令代码一样。为什么在命令执行时,我们更新整个工具栏而非相应的控制按钮呢?这是因为其它命令的状态也可能会改变。例如:右对齐(justify-right)命令运行后,左对齐(justify-left)按钮也需要更新。为了避免追踪所有类似这种互斥状态,我们更新整个工具栏。
现在,我们有了双状态命令的基础架构。粗体、斜体、左对齐、右对齐、居中对齐都可以此实现。
链接

在实现了一些基本文本格式化命令之后,我决定给站点访问者们以在文档中添加链接的能力。在内建的createLink命令没有完全按照我们所想的工作之前,链接控制(link control)需要更多的自定义命令逻辑。内建的命令虽然也能创建链接,但是并不返回选区是否在链接之内的信息。我们需要这一特性来为工具栏提供一致性。
那么我们怎么来检查选区是否在一个链接中呢?答案是创建一个功能方法:getContaining,它能够遍历当前选区的DOM树,直到找到我们想要的节点(找不到就返回none)。我们用它来检查是否包含a元素,如果包含的话,那么当前选区就处于一个链接之中。
另一个扩展是我们需要让用户输入链接URL。一个新颖的设计或许会用自定义对话框来完成,但为了简单起见,我们仅用内建的window.prompt方法。如果选区在链接中,我们想在对话框中显示当前链接,这样用户就能检查或者修改它了。或者我们只显示默认的前缀http://。
以下是Linkcommand方法的代码:

js 代码
关于

1. function LinkCommand(editDoc) {
2. var tagFilter = function(elem){ return elem.tagName=="A"; }; //(1)
3. this.execute = function() {
4. var a = getContaining(editWindow, tagFilter); //(2)
5. var initialUrl = a ? a.href : "http://"; //(3)
6. var url = window.prompt("Enter an URL:", initialUrl);
7. if (url===null) return; //(4)
8. if (url==="") {
9. editDoc.execCommand("unlink", false, null); //(5)
10. } else {
11. editDoc.execCommand("createLink", false, url); //(6)
12. }
13. };
14. this.queryState = function() {
15. return !!getContaining(editWindow, tagFilter); //(7)
16. };
17. }

function LinkCommand(editDoc) {
var tagFilter = function(elem){ return elem.tagName=="A"; }; //(1)
this.execute = function() {
var a = getContaining(editWindow, tagFilter); //(2)
var initialUrl = a ? a.href : "http://"; //(3)
var url = window.prompt("Enter an URL:", initialUrl);
if (url===null) return; //(4)
if (url==="") {
editDoc.execCommand("unlink", false, null); //(5)
} else {
editDoc.execCommand("createLink", false, url); //(6)
}
};
this.queryState = function() {
return !!getContaining(editWindow, tagFilter); //(7)
};
}

这个方法的逻辑是:

1. 方法首先检查一个元素是否是我们正要找的。tagName总是以大写字母从DOM那里返回,不管源代码中的是大小写。
2. getContaining方法寻找拥有特殊name的元素,包括当前选区。如果找不到就返回null。
3. 如果找到一个链接,我们在对话框中插入href属性;否则插入默认的http://。
4. prompt返回null如果用户点击了取消按钮。它将放弃执行命令。
5. 如果用户删除了url并且点了确定按钮,我们假定用户想要完全删除此链接。我们使用内建的unlink命令来完成它。
6. 如果用户提供了一个url并且点了确定按钮,我们使用内建的createLink命令来创建链接。(假如链接已经存在,命令将使用新的url值更新链接的href属性)
7. 双惊叹号的作用是将结果转换为boolean值——如果找到元素为true,找不到为false。

我们可以将LinkCommand和ToggleCommandController联合起来,因为所有的工具栏控制接口都是一样的:执行(execute)和查询状态(queryState)方法。
获得包含(GetContaining)

现在让我们看看getContaining方法吧(editlib.js文件中),它能告诉我们当前选区在元素中的哪个部位。
事情稍微变得有些复杂,因为IE的API和其它浏览器还不一样。那样的话,我们需要创建两个独立的实现,并根据检查getSelection属性来决定使用哪一个,就像这样:

js 代码
关于

1. var getContaining = (window.getSelection)?w3_getContaining:iegetContaining;

var getContaining = (window.getSelection)?w3_getContaining:iegetContaining;

IE的实现相当有趣,因为它详细展示了IE选区API的微妙之处。

js 代码
关于

1. function ie_getContaining(editWindow, filter) {
2. var selection = editWindow.document.selection;
3. if (selection.type=="Control") { //(1)
4. // control selection
5. var range = selection.createRange();
6. if (range.length==1) {
7. var elem = range.item(0); //(3)
8. }
9. else {
10. // multiple control selection
11. return null; //(2)
12. }
13. } else {
14. var range = selection.createRange(); //(4)
15. var elem = range.parentElement();
16. }
17. return getAncestor(elem, filter);
18. }

function ie_getContaining(editWindow, filter) {
var selection = editWindow.document.selection;
if (selection.type=="Control") { //(1)
// control selection
var range = selection.createRange();
if (range.length==1) {
var elem = range.item(0); //(3)
}
else {
// multiple control selection
return null; //(2)
}
} else {
var range = selection.createRange(); //(4)
var elem = range.parentElement();
}
return getAncestor(elem, filter);
}

它是这样工作的:

1. 选区的type属性可能为“Control”或者“Text”中的一种。什么时候会出现Control情况呢?不止一个控制区被选中时就会如此(例如,用户按下ctrl键选取了几个不连续的图像)。
2. 我们并不处理多个选区的情况,此时会退出命令不执行,这样什么都不会发生。
3. 如果有且仅有一个选区,就高亮它。
4. 如果它是一个文本选区,我们就这样获取它的容器元素(container element)。

其它浏览器使用的API比较简单:

js 代码
关于

1. function w3_getContaining(editWindow, filter) {
2. var range = editWindow.getSelection().getRangeAt(0); //(1)
3. var container = range.commonAncestorContainer; //(2)
4. return getAncestor(container, filter);
5. }

function w3_getContaining(editWindow, filter) {
var range = editWindow.getSelection().getRangeAt(0); //(1)
var container = range.commonAncestorContainer; //(2)
return getAncestor(container, filter);
}

它是这样工作的:

1. 如果API允许选择多个选区,但是UI只接受一个,那么我们就把第一个选区看作是唯一的范围(range)。
2. 这个方法可以得到包含当前选区的元素。

getAncestor方法很简单——查看整个元素体系,找到我们需要寻找的目标,或者找不到的话返回null。

js 代码
关于

1. /* walks up the hierachy until an element with the tagName if found.
2. Returns null if no element is found before BODY */
3. function getAncestor(elem, filter) {
4. while (elem.tagName!="BODY") {
5. if (filter(elem)) return elem;
6. elem = elem.parentNode;
7. }
8. return null;
9. }

/* walks up the hierachy until an element with the tagName if found.
Returns null if no element is found before BODY */
function getAncestor(elem, filter) {
while (elem.tagName!="BODY") {
if (filter(elem)) return elem;
elem = elem.parentNode;
}
return null;
}

多值命令

像编辑字体、大小等需要不同的途径,因为它们各自都有好几个不同的可选项。对于UI窗口来说,此时就不应该还像前面一样用双状态按钮(two- state button),而是使用下拉框(select box)了。当然替代简单的开关状态(on/off state),我们还需要一系列命令和控制器(Command and Controller)来处理多值情况。
这是字体选择器的html代码:

html 代码
关于

1. <select id="fontSelector">
2. <option value="">Default</option>
3. <option value="Courier">Courier</option>
4. <option value="Verdana">Verdana</option>
5. <option value="Georgia">Georgia</option>
6. </select>

<select id="fontSelector">
<option value="">Default</option>
<option value="Courier">Courier</option>
<option value="Verdana">Verdana</option>
<option value="Georgia">Georgia</option>
</select>

command对象依然很简单,因为它基于内建的FontName命令:

js 代码
关于

1. function ValueCommand(command, editDoc) {
2. this.execute = function(value) {
3. editDoc.execCommand(command, false, value);
4. };
5. this.queryValue = function() {
6. return editDoc.queryCommandValue(command)
7. };
8. }

function ValueCommand(command, editDoc) {
this.execute = function(value) {
editDoc.execCommand(command, false, value);
};
this.queryValue = function() {
return editDoc.queryCommandValue(command)
};
}

值命令(ValueCommand)和前面提到的双状态命令不同之处在于,它有一个queryValue方法,能够返回字符串类型的当前值。当用户选择下拉列表中的某个值时,控制器就开始执行命令。

js 代码
关于

1. function ValueSelectorController(command, elem) {
2. this.updateUI = function() {
3. var value = command.queryValue();
4. elem.value = value;
5. }
6. bindEvent(elem, "change", function(evt) {
7. editWindow.focus();
8. command.execute(elem.value);
9. updateToolbar();
10. });
11. }

function ValueSelectorController(command, elem) {
this.updateUI = function() {
var value = command.queryValue();
elem.value = value;
}
bindEvent(elem, "change", function(evt) {
editWindow.focus();
command.execute(elem.value);
updateToolbar();
});
}

控制器很简单,因为我们直接将选项的值映射到命令值上。
字体大小选择使用相同的方式实现。我们只是用内建的FontSize方法来代替,并且使用1-7作为size的选项值。
自定义命令

直到现在,所有的html修改都已经通过内建的命令完成了。但是有时候你可能需要内建命令并不支持的修改行为。这时候我们就要用到DOM和Range API了。
作为一个例子,我们创建一个命令用来在插入点插入一些自定义html代码。简单起见,我们只插入一个text为“Hello World”的span元素。你可以以此为基础扩展插入任何你喜欢的html代码。
这个命令是这样的:

js 代码
关于

1. function HelloWorldCommand() {
2. this.execute = function() {
3. var elem = editWindow.document.createElement("SPAN");
4. elem.style.backgroundColor = "red";
5. elem.innerHTML = "Hello world!";
6. overwriteWithNode(elem);
7. }
8. this.queryState = function() {
9. return false;
10. }
11. }

function HelloWorldCommand() {
this.execute = function() {
var elem = editWindow.document.createElement("SPAN");
elem.style.backgroundColor = "red";
elem.innerHTML = "Hello world!";
overwriteWithNode(elem);
}
this.queryState = function() {
return false;
}
}

奇迹发生在overwriteWithNode那里,它会在插入点插入一个元素。(它的名字也指出,如果当前是一个非空选区,被选择的内容会被覆盖)。显而易见的是,IE和DOM 范围标准浏览器之间的实现也不同。让我们先来看看DOM标准浏览器:

js 代码
关于

1. function w3_overwriteWithNode(node) {
2. var rng = editWindow.getSelection().getRangeAt(0);
3. rng.deleteContents();
4. if (isTextNode(rng.startContainer)) {
5. var refNode = rightPart(rng.startContainer, rng.startOffset)
6. refNode.parentNode.insertBefore(node, refNode);
7. } else {
8. var refNode = rng.startContainer.childNodes[rng.startOffset];
9. rng.startContainer.insertBefore(node, refNode);
10. }
11. }

function w3_overwriteWithNode(node) {
var rng = editWindow.getSelection().getRangeAt(0);
rng.deleteContents();
if (isTextNode(rng.startContainer)) {
var refNode = rightPart(rng.startContainer, rng.startOffset)
refNode.parentNode.insertBefore(node, refNode);
} else {
var refNode = rng.startContainer.childNodes[rng.startOffset];
rng.startContainer.insertBefore(node, refNode);
}
}

range.deleteContents方法具有如下作用:如果选区是非折叠的,它将删除选区的内容。(如果选区是空的,它不会删除任何东西)。
DOM的Range对象允许我们定位插入点的位置:startContainer是包含插入点的节点,startOffset是插入点在父节点中的偏移量。
例如:如果startContainer是个元素并且startOffset等于3,那么插入点就位于这个节点的第3个子节点和第4个子节点之间。如果 startContainer是个文本节点,startOffset则标识插入点的字符位置。比如说startOffset等于3就是说插入点在第3个和第4个字符之间。

endContainer和endOffset以相同方式标识插入点的结束位置。如果选区为空,它们和startContainer、startOffset相等。

如果插入点在文本中间,那么文本就被一分为二,我们可以在中间插入想要的内容。rightPart是个有用的方法,它将文本节点分成两半并返回右边那一部分。这样就可以使用insertBefore在合适的位置插入新节点了。
IE的版本多少有些棘手。IE的Range对象并不能立即获得插入点的精确位置,另外我们只能用pasteHTML方法——它只专横地接受字符串格式的html代码为参数而非dom节点。
基本上,IE的范围API和DOMAPI完全孤立。
然而,我们仍然有办法将这两大体系链接起来:使用pasteHTML方法插入一个标记元素并为之赋予唯一的ID,随后可以用这个ID在DOM中找到它:

js 代码
关于

1. function ie_overwriteWithNode(node) {
2. var range = editWindow.document.selection.createRange();
3. var marker = writeMarkerNode(range);
4. marker.appendChild(node);
5. marker.removeNode(); // removes node but not children
6. }
7.
8. // writes a marker node on a range and returns the node.
9. function writeMarkerNode(range) {
10. var id = editWindow.document.uniqueID;
11. var html = "";
12. range.pasteHTML(html);
13. var node = editWindow.document.getElementById(id);
14. return node;
15. }

function ie_overwriteWithNode(node) {
var range = editWindow.document.selection.createRange();
var marker = writeMarkerNode(range);
marker.appendChild(node);
marker.removeNode(); // removes node but not children
}

// writes a marker node on a range and returns the node.
function writeMarkerNode(range) {
var id = editWindow.document.uniqueID;
var html = "";
range.pasteHTML(html);
var node = editWindow.document.getElementById(id);
return node;
}

注意在完成后我们移除了标记节点,这是为了防止html被这些废弃节点弄乱。
我们现在有了在插入点插入任意html代码的命令,并使用工具栏按钮和ToggleCommandController方法将它们链接到UI上。
总结

在这篇文章中,我们整体结构上了解并制作了一款简单的html编辑器框架。这些代码可以作为你的起点,用以开发更加高级和自定义的编辑器。