内容


构建一个简单的 WYSIWYG Web 页面编辑器

Comments

本文描述一个简单的系统,该系统让 Web 站点用户可以构建自己的 Web 页面。通过这个系统,用户可以在它们的页面上放置和安排文本和图像,然后保存他们的工作。本文中的代码是独立的,没有使用第三方的库。虽然在现实中您未必这么做,但这为研究实现技巧提供了很好的基础。

体系结构

这个系统中的代码可分为 5 个部分:

  • 小部件:小部件 是组成 Web 页面的一个个的元件。本文只考虑两种小部件:一种是可编辑的文本小部件,另一种是图像小部件。当然,您也可以创建很多其他类型的小部件。但是,这里我更感兴趣的是支持小部件的基础设施,而不是多种多样的选项。
  • 布局:这个系统的整个功能就是让您可以通过创建、移动和缩放文本和图像小部件来创建 Web 页面。这里并没有发生什么大事情 — 只是一些鼠标事件处理器、<div> 大小调整等。很多文章和教程已对此作了大量的描述,本文不再重复。
  • 持久性:用户必须保存他们的工作,并在以后再次装载它,所以需要一个持久性机制。您将使用基本的串行化将数据转换成可保存的格式,并使用文档对象模型(Document Object Model,DOM)存储来存储它。DOM Storage 是在 HTML version 5 规范中定义的,较新版本的 Mozilla Firefox 中实现了该功能。
  • 点击-拖动功能:网上有很多关于用 JavaScript 代码实现点击-拖动功能的文章和教程,所以在此不再赘述。相反,我关注实现它的基本结构,因为我认为它是模块化的。
  • 拖放功能:和点击-拖动功能一样,可以完全用 JavaScript 代码实现拖放功能。但是,现代浏览器和操作系统越来越多地支持本地拖放功能,这样做有利也有弊。本文会讨论这些问题。

小部件

根据 Wikipedia 上的定义,小部件是 “一个对象的占位符名称,具体而言,它是一个机械设备或已制成的设备的占位符名称”。在软件领域,小部件常常指自含的 GUI 元素,可以随意将它们放在一个页面上,或者将它们组合成一个整体。

这个系统包含两种小部件:一种用于文本,另一种用于图像。文本小部件是可编辑的,图像小部件则不能编辑。这是非常基本的一组小部件,与商业系统中多种多样的小部件区别很大,但能够满足演示的目的。本文感兴趣的是围绕小部件发生的事情。

小部件属于对象

JavaScript 是一种面向对象语言,所以小部件就是对象。但是,JavaScript 语言是非常灵活的。它更倾向于基于原型,而不是基于类,它没有单独的、内建的继承技术。这两个小部件首先是 GUI 元素。实际上,Image 对象是一个 HTMLImageElement。它的构造函数如清单 1 所示。

清单 1. Image 的构造函数
function Image( url ) {
  var self = elem( "img" );
  self.src = url;
  // ...
  return self;
}

这种风格的构造函数并不常见,它返回一个称作 self 的值。在 JavaScript 语言中,构造函数实例化由运行时系统提供的底层的 Object,这个 Object 返回给构造函数的调用者。

但是,这个构造函数返回一个值,这意味着这个值取代常规的 Object,而后者将被抛弃。定制对象覆盖了默认的 Object

注意:我将这个值称作 self,因为我不能重新赋值传统的 this。这导致的结果是,Image 对象实际上是通过调用 elem() 创建的一个 HTMLImageElement。这意味着 ImageHTMLImageElement 的一个子类,可以将它直接放到 DOM 树中。

可编辑的小部件

这个系统中的另一种小部件是文本小部件,它被称作 EditText,因为文本是可编辑的。EditText 构造函数也覆盖构造函数返回值,如清单 2 所示。

清单 2. EditText 的构造函数
function EditText( text )
{
  var self = Box();
  self.shower = elem( "div" );
  // ...
  return self;
}

self 对象是由 Box 构造函数创建的,后者也覆盖返回值,如清单 3 所示。

清单 3. Box 的构造函数
function Box()
{
  var self = elem( "div" );
  sty( self, "border", "none" );
  // ...
  return self;
}

因此,EditText 对象实际上是一个 Box 对象,后者实际上又是一个 HTML DivElement。因此也可以将它直接放入到 DOM 树中。

Box 对象是一个 <div>,其中包含另一个 GUI 元素。它有一对 getting 和 setting 方法,用于访问和设置这个包含的元素。EditText 元素需要这些元素,因为 EditText 有两种模式。当编辑文本时,它是一个文本区,当显示时,它是一个常规的 <div>。当 EditText 对象从一种状态切换到另一种状态时,它调用 Box.set() 方法来更改外观。

持久性

Web 上的持久性一直都比较复杂,因为要由浏览器负责防止 Web 页面中运行的代码做出任何损害硬盘的事情。但持久性一个必需的功能,所以有很多实现方法。

DOM Storage

本系统中的代码使用 DOM Storage,这是 HTML version 5 规范中定义的一项新技术。DOM Storage 提供一组简单的 JavaScript 变量,这些变量在代码装载之间仍可持久。但是,DOM Storage 只能存储字符串。因此,必须将保存的数据转换为字符串。

本系统中的小部件很简单。Image 小部件有一个图像 URL;EditText 小部件有一个字符串。任何一个小部件都有位置、宽度和高度。gather() 函数迭代页面上的所有小部件,并将该信息收集到一个单独的数据结构中。然后,将这个数据结构转换成一个字符串,之后就可以用 DOM Storage 来存储它。

存储的字符串实际上是 JavaScript 源代码,它执行时会产生原始的数据结构。这样很好,因为它不需要 2 个新的函数(serialize()unserialize()),而只需要一个新的函数 .toSource()— 常规的 eval() 已经包含在该语言中。当执行完代码并产生数据结构时,只需迭代它,依次创建每个小部件。

点击-拖动功能

用 JavaScript 语言实现可拖动的对象,这项技术早已有之,这里只需要一些精心实现的鼠标处理程序。但是,要让一切按预期运行则比较复杂。

GUI 编程是非模块化的;在 GUI 编程中,很难以一组较简单的交互为基础构建更复杂的交互。但是,模块化总是值得追求的东西,因此在这里我说说我曾经的做法。

接下来是一系列的便利函数,每个函数负责点击-拖动过程的一个方面。它们不仅节省了大量的开发时间,而且提供了一种一致的方法,这种方法同样适合于其他处理程序 — 甚至适合其他点击-拖动处理程序。每个函数都相当小,但是它们隐藏了大量烦人的细节。

CND 对象

CND 或 Click-and-Drag,是由 3 个函数组成的一个组合,它形成了点击-拖动函数的一个接口。这 3 个方法是:

  • start()
  • move( x, y )
  • end()

单击鼠标按钮后,调用 start 方法,释放鼠标按钮后,调用 end 方法。在这两个方法调用之间,move 方法接收每次鼠标移动的坐标。

将这三个方法绑在一起很有好处,因为这样可以在一个地方定义整个过程,而不必在代码中不同位置实现的不同事件处理程序中进行定义。另一个好处是,它隐藏了事件对象和处理程序返回值的细节。它只提供基本的东西。

安装处理程序

实现点击-拖动功能或任何其他多步 GUI 操作的另一个复杂的方面是,您所实现的处理程序可能将取代其他的处理程序。在内部,点击-拖动过程从目标对象的一个 onclick 方法开始。但是,对拖动的处理必须在文档对象中运行,因为要获得所有鼠标移动事件,而不仅仅是光标在目标对象上时发生的鼠标事件。

但是,在文档对象中加入处理程序有一定的风险。那样的处理程序会接收来自整个页面的事件,这意味着其他页面元素可能停止接收它们所需要的事件。而且,文档对象可能已经安装了一些用于其他用途的处理程序,您不可以覆盖它们。

事后清理

本系统使用一组可事后进行清理的处理程序函数。这是通过调用 install_mouse_handlers_into_target() 来实现的,后者将一组鼠标事件处理程序安装到对象中,并返回一个还原器函数。调用这个还原器函数时,它会将原始的处理程序放回原处。如果在安装处理程序时总是添加这个函数,那么就永远不必担心您的处理程序会破坏事件处理系统中的其他部分。

自动清理

对于点击-拖动功能这一特定的情况,有一种更容易 —同时更安全— 的方法来安装处理程序。每个点击-拖动操作最后都是释放鼠标按钮,此时您希望移除在点击-拖放操作开始时安装的所有处理程序。

4() 函数负责这件事。和 install_mouse_handlers_into_target() 一样,4() 添加一组鼠标处理程序和一个对象(以便将这组处理程序安装到这个对象中)。但是在此之前,它修改 onmouseup 处理程序,除了当前在做的事情以外,它还调用清除器,以自动清除所有东西。清单 4 显示了这部分代码。

清单 4. 4() 函数
  var orig_up = onmouse.up;
    onmouse.up = function( e ) {
    restorer.restore();
    return orig_up( e );
  };

在对 onmouseup 处理程序进行了这样的修改之后,所有处理程序都被传递给 install_mouse_handlers_into_target()

修改后的 onmouseup 处理程序是一个复合的处理程序。它首先调用还原器,以清除所有的点击-拖动处理程序,然后调用调用者提供的原始的 onmouseup 处理程序。这样一来,鼠标处理程序就不必在事后负责清理工作 — 它们只需负责点击-拖动操作本身。

初始化

至此,您已经简化了最后清理处理程序的过程,接下来要简化点击-拖动过程的开始。

点击-拖动操作从一个 onmousedown 处理程序开始,它监听点击动作,并安装所有的处理程序。可以用另一个便利函数抽象这个过程,如清单 5 所示。

清单 5. 5() 函数
function install_onmouse_installer_onclick( target, onmouse ) {
  target.onmousedown = function() {
    install_mouse_handlers_until_mouseup( document, onmouse );
    return false;
  };
}

这段代码使用和前面一样的参数:一个目标对象和一组鼠标处理程序。它安装一个 onmousedown 处理程序,后者通过调用前面的便利函数 4 完成所有的初始化。

至此,您已经自动化很多细节,使点击-拖动鼠标处理程序可以工作。那么,鼠标处理程序本身呢?它们也可以变得更简单、更加模块化。

CND 和鼠标处理程序

之前,我提到了 CND 对象,它包含 3 个方法:start()move()stop()。这些方法就是点击-拖动功能的本质。但是,它们不是事件处理程序。事件处理程序更通用,它以一个事件对象为参数,其中包含事件类型、事件坐标等。

CND 更为简单,因为它们更特定于点击-拖动功能,并且不必关心不感兴趣的其他信息。如果想在一个需要鼠标处理程序的系统中使用 CND,那么需要有一种方法将 CND 转换成鼠标处理程序。这个函数就是 6(),如清单 6 所示。

清单 6. 6() 函数
function cnd_to_onmouse( cnd ) {
  return {
  down: function( e ) { cnd.start(); },
      move: function( e ) { cnd.drag( e.clientX, e.clientY ); },
      up: function( e ) { cnd.stop(); },
      };
}

同样,它是一个很小、很简单的函数,但是它让事情简单了很多。同样,您也避免了点击-拖动处理程序所需的很多烦人的细节准备。

点击-移动功能

点击-拖动操作可用于各种不同的目的。在这个应用程序中,您使用它来执行两个任务:移动小部件并调整其大小。每个任务都可以封装到一个通用的 CND 中。清单 7 显示了 7() 函数。

清单 7. 7() 函数
function move_element_cnd( elem ) {
  var yet = false;
  var dx, dy;

  return make_cnd(
                  function() { yet = false; },
                  function( x, y ) {
                    if (!yet) {
                      dx = sz( elem.style.left ) - x;
                      dy = sz( elem.style.top ) - y;
                      yet = true;
                    }
                    elem.style.setProperty( "left", x+dx, "" );
                    elem.style.setProperty( "top", y+dy, "" );
                  },
                  null);
}

该函数做很多位置整理(position-munging)工作,但都可归结为一个简单的思想:不断保存鼠标光标与目标对象之间的精确的距离。当然,这很常见,所以它可以用在任何对象上。

点击-调整大小功能

调整大小可以和移动一样处理。调整大小并不需要拖动对象本身,但是要拖动对象和小部件(如果有的话)的一个角。清单 8 显示了 8() 函数。

清单 8. 8() 函数
function resize_element_cnd( elem ) {
  var yet = false;
  var dx, dy;

  return make_cnd(
    function( x, y ) {
      yet = false;
    },
    function( x, y ) {
      if (!yet) {
        dx = width( elem ) - x;
        dy = height( elem ) - y;
        yet = true;
      }
      setsize( elem, x + dx, y + dy );
    },
    null);
}

这个函数看上去很像 7()。实际上,可以将 8() 函数总结为 “鼠标的所有运动应该总是等于目标对象在大小上的总的变化”。同样,这个函数是完全通用的,可在任何 DOM 元素上使用。

全部组合

将所有东西放在一起很简单,如清单 9 所示。

清单 9. 用一行代码设置所有的点击-拖动处理程序
  install_cnd_installer_onclick( target, move_element_cnd( movee ) );

然后,就可以点击 target,并开始拖动。但是,被拖动的对象不是 target,而是 movee。这些对象常常相同,但并非总是如此。

例如,当调整大小时,可能需要它们是不同的对象。在本文描述的系统中,是通过点击和拖动 resize 图标来为一个对象调整大小的。而这样做又会调整小部件的大小。这种情况最好用两个 CND 对象来实现。resize 图标需要一个 move CND,而小部件需要一个 resize CND。可以使用 10() 函数将它们组合在一起,如清单 10 所示。

清单 10. 10() 函数
function compose_cnds( a, b ) {
  return make_cnd( function() { a.start(); b.start(); },
                   function( x, y ) { a.drag( x, y ); b.drag( x, y ); },
                   function() { a.stop(); b.stop(); } );
}

10() 函数以 2 个 CND 为参数,并返回一个 CND,这个 CND 将它们组合在一起,使它们依次运行。任何对复合 CND 的调用都是先调用第一个 CND,然后调用第二个 CND。清单 11 显示了 10() 函数的调用。

清单 11. 11() 函数
  install_cnd_installer_onclick( resize_icon, compose_cnds(
      move_element_cnd( widget.resizer ),
      resize_element_cnd( widget ) ) );

本节包含很多小函数,每个函数负责在实现拖放功能时处理一些烦人的重复工作。这样做有一些优点。

这种方法在编程中的方便性被大大低估。如果可以用一行代码实现一个点击-拖动功能,那么在为一个 GUI 制作原型时,您很可能更愿意尝试一下。设计 GUI 是很精细的事情,重要的是能够尝试很多事情,不断地改进设计。

这种方法也很安全。便利方法不仅方便,而且干净、安全。每个函数都不会用新的处理程序覆盖已有的处理程序,以确保在完成点击-拖动的处理后,不会留下垃圾。当您知道这些时,就很难遇到由于不同用途的不同处理程序之间的相互影响而导致的 bug。

拖放功能

拖放功能对于单个应用程序和整个操作系统而言都是必须的。但是,这个功能的实现并不容易,因为涉及到在不相关的应用程序之间共享数据,然而,不相关的应用程序往往具有不相关的数据表示。

不过这个功能一直都在发展。最近版本的浏览器和操作系统允许将选择的文本和图像拖放到不同类型的目标上,例如文本域或桌面。这对于用户来说非常方便,因为这非常符合鼠标这个无处不在的输入设备的关键设计。

Build-Your-Page 系统涉及到本地拖放功能的两个方面:使用和不使用。

使用拖放功能

可以将图像和文本拖放到一个常规的文本域中。当拖放一个图像时,会插入 URL;当拖放文本时,会插入文本本身。

在 Build-Your-Page 系统中,在页面顶端有两个文本框:一个用于图像,另一个用于文本。将某个东西拖放到其中一个文本框中之后,单击旁边的按钮,然后就可以转换成一个小部件。

这个功能实际上没有什么特别之处。浏览器或操作系统负责将 URL 或文本插入到文本域,而单击按钮则导致调用 onclick 处理程序,后者又调用 place_new_widget()

不使用拖放功能

但是拖放功能也有副作用。本地拖放功能可能干扰界面的其他部分。当我开始编写本文中的代码时,我发现 resize 按钮并非总是正常工作。有时候,浏览器似乎认为我真正想做的是将 resize 图标拖到其他地方。而且,它也不清楚在什么情况下要这样解释点击动作。

经调试发现,问题在于我没有从处理程序返回一个值。处理程序被假设为返回表明它们是处理事件还是忽略事件的值。而这又决定了是否应该将事件传送到下一个处理程序。

将一个 return false 添加到 5 中创建的处理程序的之后,就可以解决问题。这个处理程序是整个点击-拖动序列的触发器,它通过返回 False,告诉浏览器这个事件不应该被传递到任何其他地方 — 在这里,尤其是不能传递到本地拖放处理程序。

结束语

GUI 的开发并不容易。这涉及到很多微妙的问题,包括使用它们的人的心理活动。而且,由于 GUI 在不同主体之间 —用户和计算机— 划分了界限,它们需要面两种特殊的状态。这常常导致错综复杂的、非模块化的代码,在长期开发过程中,这样的代码非常脆弱。

但还是有希望的。只要谨慎地将通用的功能与特定于应用程序的功能分离出来,就可以创建 GUI 动作库,并且可以将这些 GUI 动作组合成更大的相对独立的交互系统。

本文谈了很多基础知识。文中也谈到了一些与 Build-Your-Page 系统相关的不同的元素。Build-Your-Page 是一个小型的系统,但是它涉及到实际 GUI 应用程序的元素的大部分东西。它暗示将要出现一个更高级的系统,可以实现读写 Web 的梦想。


下载资源


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Web development
ArticleID=346218
ArticleTitle=构建一个简单的 WYSIWYG Web 页面编辑器
publish-date=10172008