Cutting Edge:为ASP.NET控件加入快捷菜单

英文原文:Adding a Context Menu to ASP.NET Controls
作者:Dino Esposito
翻译:MasterLRC
源码:CuttingEdge0502.exe

          虽然快捷菜单在桌面应用程序中已经是非常通用的元素,但是由于在象ASP.NET这样的基于服务器的技术没有很好的描绘,所以在Web应用程序中快捷菜单并不是很通用。要想使用快捷菜单,浏览器必须高度支持DHTML和丰富事件模式,例如:Microsoft? Internet Explorer 5.0及更高版本,或者Netscape 6.0等。然而,各种浏览器的对象模式虽然功能上大至相同,但是成员各名字却各不相同,这就需要我们自己实现对象和事件的从一种模式到到另一种模式的转换。

这里,我们创建一个针到Internet Explorer的对象模式的ASP.NET快捷菜单。接着,我将经过修改使其功能支持其它浏览器。代码支持ASP.NET 1.x,也可以在ASP.NET2.0 Beta1下编译通过 Outline of the

  • ContextMenu Control 快捷菜单控件概述:

              MSDN Liabrary 中包含了几个DHTML快捷菜单的示例。分别提供了对这一功能的不能实现方法。一个快捷菜单就是在页面中任何位置的一组标记代码。它包括两部分内容——界面和脚本(当用户在页面右击时关联UI),UI提供一个可点击的元素的列表——菜单项——和各自的内容文本,图标,命令名(command name),目标url,提示(tooltip),和所有你认为有必要显示出来的东西。快捷菜单界面是页面的一部分,并且属于页面控件树中的一员。(太多的快捷菜单将会出现执行的问题:与大多数用户的常规情况相比,ASP.NET将发送更多的快捷菜单到浏览器)当用户在页面的一个元素上右击时,将引发一段JavaScript,这段脚本将快捷菜单移动到右击的位置显示。

              快捷菜单将保持隐藏属性,只有当用户在页面的一个绑定个快捷菜单的元素上右击时,快捷菜单才显示。页面元素接收脚本事件并弹出一个快捷菜单作为响应。脚本事件信赖于浏览器,在Internet Explorer 5.0和更新版本中是 OnContexMenu事件.在Netscape6.0和更新版本中你要使用 OnMouseUp 事件(在Internet Explorer 5.0+中你也可能使用OnMouseUp事件,但要多写几行代码)在接收事件的脚本中,获取快捷菜单的UI代码块并且将它移动到发生点击的位置。同时设置弹出的panel的 visibilty属性。当用户在菜单项上点击时,页面回传并引发服务器端事件。对于服务器而言,点击一个菜单项和点击一具常规的按钮没什么区别。

    当用户户想取消已经打开的快捷菜单时怎么办呢?在windows程序中,按下Esc键或者在菜单区域外点击都可以取消已弹出的快捷菜单。所以你必须在Web中实现这一功能。请注意,只有快捷菜单处于活动(显示出来)时,Esc键才有郊。因为Esc还可用于其它元素的其它键盘快捷方式。

    我还可以让菜单在用户鼠标移开菜单时隐藏。可以通过脚本操作 OnMouseLeave 事件实现。

  • 使用弹出菜单控件

              假定我们已以设计出了这个弹出菜单(我们一会儿再说如何实现这个快捷菜单控件),那么如何使用它在ASP.NET页面中添加一个或多个快捷菜单呢?首先,在Visual Studio? .NET的工个栏中拖一个或多个快捷菜单控件到页面上。然后,为每个菜单添加菜单项,并配置每个菜单项的的工具提示,命令名(command name)和其它所需的内容,例如快捷键和帮助主题的链接。命令名(command name)用于在响应点击快捷菜单发生页面回传时确定是哪一个菜单项被点击;对每一个快捷菜单控件实例的菜单项集合它必须是唯一的。

              你必须在HTML标签的OnContextMenu事件中加入代码来弹出快捷菜单,代码必须信赖一系列的参数,象点击的x,y坐标,点击的元素,和要使用的快捷菜单的实例等。注意:如果必要你可以使用这种方式完全代替浏览器的快捷菜单。绑定到OnContextMenu事件执行的JavaScript代码是在运行时动态生成的。ContextMenu控件将暴露一个集合属性来包含分绑定快捷菜单的控件集合,在ContextMenu控件将在运行时给这些要绑定的控件一个 oncontextmenu 属性。OK!完成!可以测试了。

              慢着,让我们先来想一下ContexMenu控件和页中任意的显示快捷菜单的元素的绑定机制,这种机制对设计时的支持怎么样?理想的情况是:根据基类每一个Web控件直接暴露一个 ContextMenuId属性。然后在属性窗口中选择这个属性时,将看到在页面中的ContextMenu控件的列表。当然这些ContextMenu控件我们是已经创建了的;ContextMenuId属性在ASP.NET 1.x中不支持,在将来的ASP.NET2.0中也不支持。

              在Visual Studio .NET 2003 集成开发环境中,ASP.NET复合控件可以很好的完成这一工作。可以通过使用类撰写组合现有控件来创作新控件。复合控件可呈现一个重新使用现有控件功能的用户界面。复合控件可以从子控件的属性合成属性并处理由子控件引发的事件。它还可以公开自定义属性和事件。

              我不选择使用复合控件有以下几个原因:一个是Visual Studio .NET 2003对于Web窗体中的控件功能的扩展支持的不好(请参见:Extender provider components in ASP.NET: an IExtenderProvider implementation),第二,在Visual Studio 2005的ASP.NET设计器中不再支持组件托盘区。Web窗体设计器现在仅支持ASP.NET控件而忽略象复合控件这样的非可视化的组件。Visual Studio 2005将不再信赖InitializeComponent节,并且不再在代码文件中自动添任何工具生成(tool-generated)的代码。ASP.NET控件也不设计成具有快捷菜单,所以要绑定快捷菜单只能通过快捷菜单控件自身的执行。这里我使用类似ASP.NET验证控件和被验证控件之间关联的形式。

  • 编程接口

    我们的ContextMenu控件从WebControl继承并执行INamingContainer接口

    public class ContextMenu : WebControl, INamingContainer

    图一控件的成员细节,如下:

    属性 描述
    AutoHide 标志当用户鼠标移出控件区域时,是否自动隐藏快捷菜单
    BoundControls 返回使用快捷菜单的控件集合
    CellPadding 返回或设置每个菜单项周围的空间的象素数
    ContextMenuItems 返回菜单项的集合
    RolloverColor 返回或设置当鼠标划过菜单项时突显的颜色
    方法 描述
    GetEscReference 返回当用户按下Esc键时用于隐藏页面中的快捷菜单的JavaScrip代码
    GetMenuReference 返回一段JavaScript代码,这段代码将关联到快捷菜单所对应的HTML元素上.
    GetOnClickReference 返回当用户在菜单区域外点击时隐藏快捷菜单的代码.
    事件 描述
    ItemCommand 当用户点击一个快捷菜单项进激发.

    关键属性是ContextMenuItmes集合属性,它包含了ContextMenuItem类型的对象集合,每一个对象表示一个菜单项。ContextMenuItem类的源码如下:

    [TypeConverter(typeof(ExpandableObjectConverter))]
    public class ContextMenuItem
    {
    public ContextMenuItem() {}
    public ContextMenuItem(string text, string commandName)
    {
    _text = text;
    _commandName = commandName;
    }
    private string _text;
    private string _commandName;
    private string _tooltip;
    public string Text
    {
    get {return _text;}
    set {_text = value;}
    }
    public string CommandName
    {
    get {return _commandName;}
    set {_commandName = value;}
    }
    public String Tooltip
    {
    get {return _tooltip;}
    set {_tooltip = value;}
    }
    }

    每个菜单项具有显示文本,命令名(command name),提示文本(tooltip)。你可以通过各方法扩展这个类,例如添加一个图片URL,一个不可用状态,或一个目标URL等。显示文本被显示于菜单项上;命令名是一个唯一标识字符串,用于指定或确定与菜单项关联的命令。tooltip获取或设置当鼠标指针停留在菜单项上时显示的工具提示文本

    当用户点击菜单项时,页面回传并激发一个服务器端的ItemCommand事件。控制页通过操作这一事件来执行一些代码来响应用户对菜单项的点击。图3是一个使用快捷菜单的示例工程的截图:

    要使用快捷菜单,你需要使用menu item对象填充ContextMenuItems集合,调整一些可视化样式,至少添加一个控件到BoundControls集合中。然后在浏览器中打开示例页,在任意绑定快捷菜单的控件上右击。效果如图:

    每个菜单项包含一个LinkButton控件,这个LinkButton控件有一个内部绑定的点击事件处理程序。当检测到点击时,页面回传并激发这个点击事件。接着,预定义的处理程序将事件冒泡到上一级,并改名为ItemCommand。

    控件还定义了一些可视化的属性,象CellPadding,RolloverColor和AutoHide。重申一下,在Windows中快捷菜单可以在按下Esc键或在菜单区域外点击时取消。对于基于Web的快捷菜单来说,AutoHide属性为快捷菜单的根标签添加OnMouseLeave脚本,所以当用户的鼠标离开菜单区域时,这个根标签的子树将隐藏。把AutoHide作为一个可设置的属性,用户可以在需要的时候设置是否在鼠标离开时自动隐藏快捷菜单。

    要使菜单能够在点击或按Esc键时隐藏,需要添加如下处理程序:< br><bodyonkeypress="..."onclick="...">

    处理程序脚本可以被程序化的添加到每一个页面元素,只要这个元素被标记为runat=server。这样实际上就是在ContextMenu快捷菜单控件和页面之间创建了一个逻辑信赖。另外,你必须在页面上定义一个额外的服务器控件。当然,在运行时实例化一个额外的控件并不会严重的影响执行效果,但是为什么仅仅因为想容易的consume其它的控件而实例化一个无用的控件呢。作为选择下面这个方法也可以达到同样的效果:用body获取按Esc键和鼠标点击,并且你节省了服务器控件的开支

    <body onkeypress="<% = ContextMenu1.GetEscReference() %>" onclick="<% = ContextMenu1.GetOnClickReference() %>">

    让我们更详细的说一下控件的实现

  • 控件的实现

    ContextMenu控件的核心在于重写CreatechildControls方法。在这个方法中控件创建界面并在页面中写入所需的脚本。我们说过,ContexMenu控件的用户界面分为两部分——图形和脚本。我们先说图形。

    CreateChildControls方法产生一个可以在页面移动的HTML块,它就是所需的弹出菜单。照这样看来,快捷菜单就是包含一个table表单的<DIV> ,每一个菜单项就是这个table表单中的一行。使用table是由于一系列的开发点(象边框和浮动层)和它能很容易的扩展(例如添加侧边图象)所决定的。

    HtmlGenericControl div = new HtmlGenericControl("div");
    div.ID = "Root";
    div.Style["Display"] = "none";
    div.Style["position"] = "absolute";
    if (AutoHide)
        div.Attributes["onmouseleave"] = "this.style.display='none'";

    我们使用层叠样式表(CSS)来隐藏最外层的<DIV>标签,并且用绝对位置标记这个<DIV>。如果自动隐藏可用,那么<DIV> 还得处理 mouse-leave(鼠标离开)事件来隐藏本身。那么 onmouseout和onmouseleave事件有什么不同呢?前者是当鼠标移动到一个新的元素上时发生,而后者是当鼠标移出绑定对象时发生。举例说明:你的鼠标在一个有两行的表单(table)上移动。当你在table的两行之间移动时,onmouseout事件就会发生;只有你的鼠标移动到table表单之外时onmouseleave事件才发生。

    Table包含和要显示的菜单项个数相同的行,每一行有一个单元格,每个单元格里放一个LinkButton对象。菜单通过一个循环创建:

    foreach(ContextMenuItem item in ContextMenuItems)
    {
    TableRow menuItem = new TableRow();
    menuTable.Rows.Add(menuItem);
    TableCell container = new TableCell();
    menuItem.Cells.Add(container);
    LinkButton button = new LinkButton();
    container.Controls.Add(button);
    ...
    }

    行的单元格有一组脚本操作——onmouseover和onmouseout——完成鼠标划过的效果。当鼠标划过时改变背景颜色。鼠标离开时恢复初始颜色。默认的背景色由从WebControl继承的background属性指定。高亮颜色由新的属性RolloverColor指定。

    string color = String.Format(ContextMenu.OnMouseOver, ColorTranslator.ToHtml(RolloverColor));
    container.Attributes["onmouseover"] = color;
    color = String.Format(ContextMenu.OnMouseOut, ColorTranslator.ToHtml(BackColor));
    container.Attributes["onmouseout"] = color;

    你需要把.NET的System.Drawing.Color值转换成可用的HTML颜色。有意思的是,无论是Color类的ToString方法还是它的Name属性都不能在所有情况下返回对应的HTML颜色字符串,不知是不是有意这样的:(。Name属性基本可以实现这一功能,只有和种情况例外。当颜色不能和已知的颜色匹配时,这个属性返回颜色的RGB组,外加alpha通道值。要得到当前的HTML颜色,你必须移除alpha通道值(通常是开头的十六进制 ff 字符串)并使用#号替换它。幸运的是,System.Drawing.ColorTranslator类可以自动完成这项工作:)。

  • (续)控件的实现

    我们接着要对链接按钮做一些调整,以使其正常工作。 也就是:把链接的宽度设成100%以确保当没有其它设置时,在整个行上光标都是手状。 同样的,要设置从对应的菜单项对象得到的文本(text),工具提示(tooltip),和命令名(command name)。最后为点击事件关联一个处理器。

    LinkButton button = new LinkButton();
    container.Controls.Add(button);
    button.Click += new EventHandler(ButtonClicked);
    button.Width = Unit.Percentage(100);
    button.ToolTip = item.Tooltip;
    button.Text = item.Text;
    button.CommandName = item.CommandName;

    回传时,事件源被识别为在ContextMenu命名容器内的LinkButton,并且得到处理回传事件的时机。点击事件的内部处理器将所有信息打包进一个新的ItemCommand 事件并引激活这个事件,参见下面的代码

    private void ButtonClicked(object sender, EventArgs e)
    {
    LinkButton button = sender as LinkButton;
    if (button != null)
    {
    CommandEventArgs args = new CommandEventArgs( button.CommandName, button.CommandArgument);
    OnItemCommand(args);
    }
    }
    protected virtual void OnItemCommand(CommandEventArgs e)
    {
    if (ItemCommand != null)
    ItemCommand(this, e);
    }

    页面上的代码将获得两部分内容:引发事件的ContextMenu实例;与点击项相关的command name。

    这时,table就是快捷菜单的用户界面。它一开始被放置在页面的任意位置,并且使用CSS样式表在视图中隐藏。在用户右击时,这段HTML代码块(使用绝对位置方式定位)将被显示成一个快捷菜单。JavaScript代码负责截取事件并把菜单移动动所需位置,参见如下代码:

    <script language="Javascript">
    
     function __showContextMenu(menu)
     {
      var menuOffset = 2
      menu.style.left = window.event.x - menuOffset;
      menu.style.top = window.event.y - menuOffset;
      menu.style.display = "";
      window.event.cancelBubble = true;
      
      return false;
     }
    
     function __trapESC(menu)
     {
      var key = window.event.keyCode;
      if (key == 27)
      {
       menu.style.display = 'none';
      }
     }
    
    </script>
       

    __showContextMenu函数设置快捷菜单对象的Top和Left属性,以使它在点击发生的位置显示。少量的偏移是确保当快捷菜单显示时,鼠标已经处于它的上面。这样也防止由于鼠标在菜单边缘的轻微移动而使菜单自动隐藏。鼠标事件的冒泡也必须被停止,这样在文档对象模型层次中高层的结点就不会捕获右击事件。

    那么由谁来调用__showContextMenu函数呢?答案是:浏览器。当浏览检测到有和HTML元素的onContextMenu事件相关联的函数时,它会调用这个函数。我们以前说过OnContextMenu事件是Internet Explorer特有的事件,Netscape浏览器不支持此事件。作为代替可以使用onmouseup事件来检测和处理松开鼠标右键的事件。

    快捷菜单也负责为每个控件或为注册快捷菜单的页面元素添加oncontextmenu事件处理器。我定义了两种方式使一个元素获取它的快捷菜单.BoundControls集合属性是一个数组。它由需要使用该快捷菜单的所有页面控件填充,并且完成填充的代码写在页面代码中.如下:

    void Page_Load(object sender, EventArgs e)
    {
     ContextMenu1.BoundControls.Add(Button1);
     ...
    }
    

    以上代码产生如下标记:

    <input type=submit ... oncontextmenu="__showContextMenu(...)" />
    

    当用户在控件上右击时会弹出快捷菜单。这种方法要求任何使用自定义快捷菜单的元素必须是服务器控件,有时这种要求是不合适的。例如:假使你想为一个图片使用自定义的快捷菜单。你必须把图片标记<img>写成 runat=server。其实没必要这样。看下面的例子:

    <img oncontextmenu="<% = ContextMenu1.GetMenuReference() %>" src="...">
    

    GetMenuReference方法返回一段用于调出快捷菜单的脚本。这样页面元素不需要定义成服务器控件也能具有所需的快捷菜单

  • 标记绝对位置

    这里开发的快捷菜单控件需要绝对位置功能。而这一功能并不是所有的浏览器都支持的。但是,一个支持复杂对象模式和丰富事件的浏览器应该具有高级的定位能力。

    就我们关心的Internet Exploer而言,还有其它方式实现快捷菜单。我们不再使用在页面中移动Div的位置这一方法,我们可以创建一个弹出窗口并在指定的位置显示它。然后将DIV动态加载到弹出窗口中呈现为快捷菜单

    当实现快捷菜单控件的时候,我首先选择了上述的创建并显示一个弹出窗口。我发现弹出窗口的一些好处,其中之一就是弹出窗口对象不需写代码就自动具有类似桌面快捷菜单的功能:在区域外单击或点击Esc键时自动隐藏。

    但是,在Internet Exploer6.0的查看代码功能中出现问题。按照我的测试,在弹出对像中包含快捷菜单改变了页面的元素树并影响查看源代码窗口,不能显示。同样,我想让快捷菜单像交互ASP.NET控件一样可以提交当前页(回传视图状态和输入域)而不只是跳转到指定的URL。从内部弹出窗口提交到父窗口仍然需要更改页面级别的棘手的代码。(这在ASP.NET 2.0中利用跨页提交功能会更简单)。在MSDN的示例中,由于它仅仅时链接到一个外部URL所以它游刃有余。

    ASP.NET回传是一个小技巧,在当前页面中使用绝对位置来维持快捷菜单不会改变页面的元素结点树。这样就没有上述的问题了。对于典型解除弹出菜单的事件捕获,你仍可使用鼠标事件捕获。这可能通过使用一系列的Internet Exploer DHTML方法来实现我,参见: How To Create a Mouse Capture Context Menu

  • 综述

    快捷菜单最重要的部分是项目项列表,它即可以在运行时通过代码来指定,也可以在设计时指定。下面的代码块是设计时指定的例子:

    <cc1:contextmenu id="ContextMenu1" runat="server">
       <cc1:ContextMenuItem Text="做这个" 
           CommandName="ThisCommand" Tooltip="¡­" /> 
       <cc1:ContextMenuItem Text="做那个" 
           CommandName="ThatCommand" Tooltip="¡­" /> 
       <cc1:ContextMenuItem /> 
       <cc1:ContextMenuItem Text="思考 ..." 
           CommandName="ThinkCommand" Tooltip="¡­ " /> 
    </cc1:contextmenu>
         

    空的<cc1:Contextmenu>标签表示一个菜单项分隔。注意,我们可以通过一系列的元数据属性设置,达到在Visual Studio.Net中操作子标签的目的:

    [DesignerSerializationVisibility(
     DesignerSerializationVisibility.Content)]
    [PersistenceMode(PersistenceMode.InnerDefaultProperty)]
    public ContextMenuItemCollection ContextMenuItems {...}
        

    但是这种配置不支持其它类型的子标签,如果你使用其它类型的子标签,解析器将会发出一个错误。例如:你不能在快捷菜单的根标签内序列化数据绑定控件的内容。通过使用一组不同的设计时属性,可以达到这一目的(我希望在将来可以包含这方面的控件设计内容)

    如果在Visual Studio.Net的设计器中双击快捷菜单控件,一个关联到快捷菜单ItemCommand事件的处理器将被自动添加。然后你就可以根据name分别处理菜单项的行为。事件处理代码中填写的内容大至如下所示:

    void ContextMenu1_ItemCommand(object sender, CommandEventArgs e)
    {
       switch(e.CommandName)
       {
          case "ThinkCommand":
               ...
               break;
          case "ThisCommand":
               ... 
               break;
          default:
               ... 
               break;
       }
    }    

    在图3中你可以看到快捷菜单控件在设计时的呈现。ASP.NET设计器调用被设计在页面内的所有控件的RenderControl方法。那么就我们的快捷菜单控件而言,将通过呈现一个table行来模拟一个选择的菜单项,这又是如何通过RenderControl方法实现的呢?这是通过快捷菜单控件的自定义设计器来实现的。你可以在本文所附的代码中找到这个组件的源代码。概括的说:自定义设计器得到由RenderControl方法产生的HTML字符串,并修改这个HTML字符串,来额外添加一个不同背景色的Table行。通过这种方式用户可以清楚的看到控件的输出效果。

  • ASP.NET 2.0 中的菜单

    本文和我们的快捷菜单控件都是基于 ASP.NEt 1.x 的,但是它也可以很容易的被用于 ASP.NET 2.0。也许你知道,在ASP.NET 2.0中有了全新的菜单控件。但是,你没必要在ASP.NET 2.0应用程序中使用这个菜单控件作为快捷菜单,因为它过于庞大,并且是被设计用于其它场景的。ASP.NET 2.0 的菜单控件是专门设计成静态菜单(而不是快捷菜单)的,并且它缺少实现快捷菜单功能所必须的两个关键设置:它不能被隐藏并且不支持绝对位置。这两个设置都可以通过编辑标记来添加声明。在另一方面,ASP.NET 2.0 的菜单提供了一些关键改进,包括:支持多级嵌套,可访问,和改变浏览器窗口大小时的滚动条。由你决定哪种形式更适合你的需求。

(全文完)