65.9K
CodeProject 正在变化。 阅读更多。
Home

上下文帮助变得容易

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (51投票s)

2007年2月2日

CPOL

10分钟阅读

viewsIcon

284082

downloadIcon

2713

本文介绍了一种新的代码检测方法,该方法使帮助作者能够随时(甚至在编译后)将帮助主题与应用程序的可视上下文关联起来,并且可以使用应用程序的用户界面来完成,而无需开发人员的参与。

目录

引言

大多数应用程序开发人员都经历过。在项目后期,你会发现自己花时间与 HTML 帮助作者合作,将应用程序的屏幕和对话框连接到上下文相关的帮助。

本文介绍了一种检测代码的方法,该方法使帮助作者能够随时(甚至在编译后)将帮助主题与应用程序的可视上下文关联起来,并且可以使用应用程序的用户界面来完成,而无需开发人员的参与。帮助作者无需手动编辑任何文件或了解任何内部信息。

The Context Mapping dialog box

背景

像我早期的文章 [^]一样,本文将引导您完成代码编写过程,为每次更改显示代码片段。我相信读者会有许多宝贵的评论、批评和建议。鼓励您在文章底部的讨论部分这样做。我将尽力将您的想法纳入文章的下一次修订中——但即使我是一个懒惰的人,没有时间做,您也将与社区中的其他人分享您的看法和想法。

什么是上下文?

当您运行应用程序(就像生活一样)时,您在任何给定时刻都处于多个上下文中。例如,如果焦点在特定的TextBox控件中,则上下文可以是所有以下内容:TextBox、包含TextBoxTabPage、包含TabPageTab控件、包含TabPage的对话框以及应用程序本身。由帮助作者决定这些上下文中的哪一个应该具有关联的帮助主题。

许多应用程序在启动上下文相关帮助时采取的一般方法是,从具有焦点的控件开始,并检查它是否具有关联的帮助主题。如果是,则应用程序启动该主题;如果不是,则向外(即,向上父链)移动到包含控件并重复该过程。

实现 F1 帮助

我们将首先实现一个系统,用于在按下 F1 键时显示关联的主题,通过使用存储在 XML 配置文件中的 ContextID 到主题映射。在此工作之后,我们将添加一个功能,使帮助作者能够从应用程序 UI 内部编辑这些映射。单击链接跳到有趣的部分。:)

IContextHelp 接口

第一步是创建一个接口 (IContextHelp),您可以将其添加到项目中的各种控件。此接口标识可以用作帮助上下文的控件,并提供唯一的字符串标识符 (ContextID) 来标识上下文。

public interface IContextHelp
{
  string ContextHelpID { get; }
}

由于此类控件通常派生自 UserControl,我们可以通过创建实现该接口的 UserControlEx 进一步简化事情。要使用它,请让您的用户控件派生自 UserControlEx 而不是 UserControl。在默认实现中,控件的 ContextID 是控件的完全限定类名。

public virtual string ContextHelpID {get{return this.GetType().FullName;}}

同样,我们创建一个 FormEx,它以相同的方式实现 IContextHelp。当您在应用程序中定义一个窗体时,将其更改为从 FormEx 而不是 Form 派生。

如果您想为标准 Windows 控件或第三方控件提供上下文帮助,您可以轻松地通过派生新控件并添加接口来实现。例如:

// PanelEx is like a Panel, except that it implements IContextHelp
public class PanelEx : Panel, IContextHelp
{
  private string m_sContextID;
  public string ContextHelpID
  {
    get
    {
      if (string.IsNullOrEmpty(m_sContextID))
        return this.Name;
      return m_sContextID;
    }
    set { m_sContextID = value; }
  } 
}

在这种情况下,我们使用控件的名称作为默认 ID,并允许在发生名称冲突时覆盖它。在实践中,对于我所参与的项目来说,这没有必要。

如果您需要为许多不同的标准 Windows 控件或第三方控件提供上下文 ID,您应该考虑实现一个 IExtenderProvider,如 Charles Williams [^] 在他对本文原始版本的出色评论 [^] 中所述。

捕获 F1 键

您可以通过向应用程序添加消息筛选器,并在收到 WM_KEYDOWN 消息时检查 F1,从而实现一个处理程序来捕获和响应整个应用程序中的 F1 键按下。

static void Main()
{
   //add message filter
   MessageFilter oFilter = new MessageFilter();
   System.Windows.Forms.Application.AddMessageFilter(
                       (IMessageFilter)oFilter);
   // etc.
}
internal class MessageFilter : IMessageFilter
{
  #region IMessageFilter Members
  bool IMessageFilter.PreFilterMessage(ref Message m)
  {
     //Use a switch so we can trap other messages in the future.
     switch (m.Msg)
     { 
       case 0x100 : // WM_KEYDOWN
         if ((int)m.WParam == (int)Keys.F1)
         {
           HelpUtility.ProcessHelpRequest(Control.FromHandle(m.HWnd));
           return true;
         }
         break;
     }
     return false;
  }
  #endregion
}

处理 F1 键

现在我们已经解决了基础设施问题,是时候展示如何处理 F1 键了。稍后,我们将修改此方法以测试 Control 键,但目前它看起来像这样:

public static class HelpUtility
{
  public static void ProcessHelpRequest(Control ctrContext)
  {
    ShowContextHelp(ctrContext);
  }
}

ShowContextHelp() 方法沿父链向上遍历,查找一个满足以下条件的控件:

  1. 实现了 IContextHelp
  2. 具有非空的 IContextHelp.ContextHelpID,并且
  3. 在映射 XML 文件中具有相应的条目。

如果找到,它将启动帮助查看器以显示它。如果找不到,它将启动默认主题。

// Process a request to display help
// for the context specified by ctrContext.
public static void ShowContextHelp(Control ctrContext)
{
  Control ctr = ctrContext;

  string sHTMLFileName = null;
  while (ctr != null)
  {
    // Get the first control in the parent chain
    // with the IContextHelp interface.
    IContextHelp help = GetIContextHelpControl(ctr);
    // If there isn't one, display the default help for the application.
    if (help == null)
      break;
    // Check to see if it has a ContextHelpID value.
    if (help.ContextHelpID != null)
    {
      // Check to see if the ID has a mapped HTML file name.
      sHTMLFileName = LookupHTMLHelpPathFromID(help.ContextHelpID);
      if (sHTMLFileName != null && ShowHelp(ctrContext, sHTMLFileName))
        return;
    }
    // Get the parent control and repeat.
    ctr = ((Control)help).Parent;
  }
  // Show the default topic.
  ShowHelp(ctrContext, "");
}

GetIContextHelpControl() 方法遍历父链,查找 IContextHelp 控件。

// Get the first control in the parent chain
// (including the control passed in)
// that implements IContextHelp.
private static IContextHelp GetIContextHelpControl(Control ctl)
{
  while (ctl != null)
  {
    IContextHelp help = ctl as IContextHelp;
    if (help != null)
    {
      return help;
    }
    ctl = ctl.Parent;
  }
  return null;
}

ShowHelp() 方法启动 HTML 帮助,显示由 sHTMLHelp 指定的主题。

// Display the specified help page.
private static bool ShowHelp(Control ctlContext, string sHTMLHelp)
{
  try
  {
    if (string.IsNullOrEmpty(sHTMLHelp))
      Help.ShowHelp(ctlContext, HelpUtility.HelpFilePath);
    else
      Help.ShowHelp(ctlContext, HelpUtility.HelpFilePath, 
                    HelpNavigator.Topic, sHTMLHelp);
  }
  catch (ArgumentException)
  {
    // Ideally, we would return false when
    // the HTML file isn't found in the CHM file.
    // Unfortunately, there doesn't seem to be
    // a way to do this without parsing the CHM.  
    return false;
  }
  return true;
}
// Define this contstant at the top of the file.
private const string mc_sHELPFILE = "ContextHelpMadeEasy.chm";
// Return the path to the CHM file.
private static string HelpFilePath 
{
  get 
  { 
    return Path.Combine(System.Windows.Forms.Application.StartupPath, 
                        mc_sHELPFILE); 
  }
}

读取映射文件

为了完成 F1 帮助,我们需要实现从 ShowContextHelp() 调用的 LookupHTMLHelpPathFromID() 函数。此方法检查 ID 到主题的映射,如果存在映射,则返回主题文件名。映射在首次访问时从 XML 配置文件中读取,并缓存以避免每次用户按下 F1 时都读取文件。我们首先定义一个 StringDictionary 来保存映射缓存。此外,我们定义了许多常量,它们代表映射文件名以及各种 XML 元素和属性名称。

static private StringDictionary ms_sdContextPaths = null;
private const string mc_sMAPPING_FILE_NAME = "HelpContextMapping.Config";
private const string mc_sIDMAP_ELEMENT_NAME = "IDMap";
private const string mc_sCONTEXTID_ELEMENT_NAME = "ContextID";
private const string mc_sID_ATTRIBUTE_NAME = "ID";
private const string mc_sHTMLPATH_ATTRIBUTE_NAME = "HTMLPath";

MappingFilePath 属性返回映射文件的路径,该文件假定与应用程序可执行文件位于同一目录中。

private static string MappingFilePath 
{
  get { return Path.Combine(Application.StartupPath, mc_sMAPPING_FILE_NAME); } 
}

ContextPaths 属性通过仅在第一次调用时读取映射文件来实现缓存。

private static StringDictionary ContextPaths
{
  get
  {
    if (ms_sdContextPaths == null)
    {
      ms_sdContextPaths = ReadMappingFile();
    }
    return ms_sdContextPaths;
  }
}

ReadMappingFile() 方法创建一个 StringDictionary 并用从 XML 帮助映射配置文件中读取的信息填充它。

// Read the mapping file to create a list of ID to HTML file mappings.
private static StringDictionary ReadMappingFile()
{
  StringDictionary sdMapping = new StringDictionary();
  XmlDocument docMapping = new XmlDocument();
  if (File.Exists(MappingFilePath) == true)
  {
    try { docMapping.Load(MappingFilePath); }
    catch
    {
      MessageBox.Show(string.Format("Could not read help mapping file '{0}'.",
            MappingFilePath), "Context Help Made Easy", MessageBoxButtons.OK,
            MessageBoxIcon.Error);
      throw;
    }
    XmlNodeList nlMappings = docMapping.SelectNodes("//" + 
                                   mc_sCONTEXTID_ELEMENT_NAME);
    foreach (XmlElement el in nlMappings)
    {
      string sID = el.GetAttribute(mc_sID_ATTRIBUTE_NAME);
      string sPath = el.GetAttribute(mc_sHTMLPATH_ATTRIBUTE_NAME);
      if (sID != "" && sPath != "")
          sdMapping.Add(sID, sPath);
    }
  }
  return sdMapping;
}

映射文件看起来像这样:

<?xml version="1.0"?>
<IDMap>
<ContextID ID="namespace.fsettings" HTMLPath="SettingsTopic.htm" />
<ContextID ID="controlname1" HTMLPath="Control1Topic.htm" />
<ContextID ID="overridenidctl2" HTMLPath="Control2Topic.htm" />
</IDMap>

实现了缓存之后,LookupHTMLHelpPathFromID() 的实现变得微不足道。

// Given an ID, return the associated HTML Help path
private static string LookupHTMLHelpPathFromID(string sContextID)
{
  if (ContextPaths.ContainsKey(sContextID))
    return ContextPaths[sContextID];
  return null;
}

终于!我们完成了 F1 的实现,现在可以进入真正酷的部分——检测您的代码,让帮助作者在没有您参与的情况下实现上下文相关帮助。

检测代码 - 赋能帮助作者

现在我们几乎拥有将帮助作者的工作与开发人员的工作分离所需的一切。通过上述代码,作者可以手动修改 XML 配置文件以添加和修改帮助映射——如果他们知道他们想要为其提供帮助的每个应用程序上下文的 ContextID

这里的想法是,在已经开发好的基础设施下,*我们可以将应用程序本身变成配置文件的上下文相关编辑器。*我们将修改代码,以便当 HTML 帮助作者导航到某个屏幕时,他或她可以单击 Ctrl-F1 并获得一个对话框,该对话框显示该屏幕所有可用的 ContextID。然后,HTML 帮助作者可以使用该对话框添加或删除 ContextID 到 HTML 文件名的关联,并将生成的关联写入帮助映射配置文件。单击确定后,他们可以立即按下 F1 并看到上下文相关帮助出现。

捕获 Ctrl-F1

我们可以通过添加对 Control 键的检查来修改上面描述的 ProcessHelpRequest() 方法,如下所示:

public static void ProcessHelpRequest(Control ctrContext)
{
  if (Control.ModifierKeys == Keys.Control)
  {
    ShowHelpMappingDialog(ctrContext);
    return;
  }
  ShowContextHelp(ctrContext);
}

实际上,您可能需要添加一个额外的测试,以防止最终用户不小心按下 Ctrl-F1 而看到此对话框。在我的实现中,对控制键的测试与注册表检查某个键值是否启用此功能进行 AND 运算。当然,您可以检查应用程序配置文件或任何其他可以唯一标识帮助作者的内容。

显示映射对话框

现在,如果在处理 WM_KEYDOWN 消息时按下了 Control 键,则不显示指定控件的帮助,而是调用 ShowHelpMappingDialog() 方法。

// Traverse the parent control chain looking for controls that implement the
// IContextHelp interface.  For each one found, add it to the list of available
// contexts.  Include the associated HTML path if it's define
// Finally, show the dialog for the help author to edit the mappings.
public static void ShowHelpMappingDialog(Control ctrContext)
{
  IContextHelp help = GetIContextHelpControl(ctrContext);
  List<ContextIDHTMLPathMap> alContextPaths = new List<ContextIDHTMLPathMap>();
  // Create a list of contexts starting with the current help context
  // and moving up the parent chain.
  while (help != null)
  {
    string sContextID = help.ContextHelpID;
    if (sContextID != null)
    {
      string sHTMLHelpPath = LookupHTMLHelpPathFromID(sContextID);
      alContextPaths.Add(new ContextIDHTMLPathMap(sContextID, sHTMLHelpPath));
    }
    help = GetIContextHelpControl(((Control)help).Parent);
  }
  // Pop up the mapping dialog. If it returns true, this means a change was made
  // so we rewrite the XML mapping file with the new information.
  if (FHelpMappingDialog.ShowHelpWriterHelper(alContextPaths) == true)
  {
    foreach (ContextIDHTMLPathMap pathMap in alContextPaths)
    {
      if (!string.IsNullOrEmpty(pathMap.ContextID))
      {
        if (!string.IsNullOrEmpty(pathMap.HTMLPath))
        {
          ContextPaths[pathMap.ContextID] = pathMap.HTMLPath;
        }
        else
        {
          if (ContextPaths.ContainsKey(pathMap.ContextID))
            ContextPaths.Remove(pathMap.ContextID);
        }
      }
    }
    SaveMappingFile(ContextPaths);
  }
 }
}

通过调用 FHelpMappingDialog.ShowHelpWriterHelper() 打开的映射对话框将在下面讨论。它基本上充当 ContextIDHTMLPathMap 结构列表的编辑器。

ContextIDHTMLPathMap 结构包含两个字符串和一个构造函数

// Utility class for maintaining relationship between context Id and path.
public class ContextIDHTMLPathMap
{
  public string ContextID;
  public string HTMLPath;
  public ContextIDHTMLPathMap(string ID, string Path)
  {
    ContextID = ID;
    HTMLPath = Path;
  }
}

写入结果

由于我们希望立即将更改写入配置文件,因此我们将其实现为直写式缓存。从 ShowHelpMappingDialog() 调用的 SaveMappingFile() 方法负责此操作。

//  Saves the specified StringDictionary that contains ID to Path mappings to the
//  XML mapping file.
private static void SaveMappingFile(StringDictionary sdMappings)
{
  // Create a new XML document and initialize it with the XML declaration and the
  // outer IDMap element.
  XmlDocument docMapping = new XmlDocument();
  XmlDeclaration xmlDecl = docMapping.CreateXmlDeclaration("1.0", null, null);
  docMapping.InsertBefore(xmlDecl, docMapping.DocumentElement);   
  XmlElement elIDMap = AddChildElementToNode(docMapping, docMapping,
    mc_sIDMAP_ELEMENT_NAME);
  // Add the defined mappings between contextID and filename.
  foreach (DictionaryEntry de in sdMappings)   
  {
    XmlElement elMapping = AddChildElementToNode(elIDMap, docMapping,
      mc_sCONTEXTID_ELEMENT_NAME);
    elMapping.SetAttribute(mc_sID_ATTRIBUTE_NAME, de.Key as string);
    elMapping.SetAttribute(mc_sHTMLPATH_ATTRIBUTE_NAME, de.Value as string);
  }
  try
  {
    docMapping.Save(MappingFilePath);
  }
  catch
  {
    MessageBox.Show(string.Format("Could not write help mapping file '{0}'",
      MappingFilePath), "Context Help Made Easy", MessageBoxButtons.OK,
      MessageBoxIcon.Error);
    throw;
  }
}

AddChildElementToNode() 实用函数使代码更具可读性

// Small utility method to add XML elements to a parent node.
private static XmlElement AddChildElementToNode(XmlNode node, 
               XmlDocument doc, string elementName)
{
  XmlElement el = doc.CreateElement(elementName);
  node.AppendChild(el);
  return el;
}

映射对话框

映射对话框由一个 ListView 控件以及用于编辑主题文件名、确定和取消的按钮组成。它是一个包含当前控件上下文的 ContextIDHTMLPathMap 结构列表的编辑器。该对话框填充 ListView 控件,允许编辑其中的 HTML 文件名,并将结果写回 List

对话框的入口点是静态方法 ShowHelpWriterHelper(),它实例化表单并初始化其数据。

// Static entry point to pop up this form.
public static bool 
       ShowHelpWriterHelper(List<ContextIDHTMLPathMap> contextIDs)
{
  FHelpMappingDialog frmHelper = new FHelpMappingDialog();
  frmHelper.IDList = contextIDs;  // Populate the treelist.
  if( frmHelper.lvMapping.Items.Count > 0 )
    frmHelper.lvMapping.SelectedIndices.Add(0);
  frmHelper.ShowDialog();         // Popup the form.
  if (frmHelper.Changed)
  {
    // For each item in the ListView,
    // change the path map to correspond to the UI.
    foreach (ListViewItem lvi in frmHelper.lvMapping.Items)
    {
      ContextIDHTMLPathMap pathMap = (ContextIDHTMLPathMap)lvi.Tag;
      pathMap.HTMLPath = (string)lvi.SubItems[0].Text.Trim();
    }
  }
  return frmHelper.Changed;
}

ListViewIDList 属性 set 方法中填充

// Gets and sets the list of ids.  The setter updates the UI.
public List<ContextIDHTMLPathMap> IDList
{
  set 
  {
    lvMapping.Items.Clear();
    foreach (ContextIDHTMLPathMap pathMap in value)
    {
      AddMappingNode(pathMap);
    }
  }
}

// Utility to add a node to the treelist.
private void AddMappingNode(ContextIDHTMLPathMap pathMap)
{
  ListViewItem lvi = new ListViewItem(pathMap.HTMLPath);
  lvi.SubItems.Add(pathMap.ContextID);
  lvi.Tag = pathMap;
  lvMapping.Items.Add(lvi);
}

对话框中的其他方法与您期望的可能一样

// Begin editing the label of the selected
// item when they click this button
private void btnEditTopicFile_Click(object sender, EventArgs e)
{
  if( lvMapping.SelectedItems.Count == 1 )
  {
    ListViewItem lvi = lvMapping.SelectedItems[0];
    lvi.BeginEdit();
  }
}

// If the item has changed after editing
// the label, flag the dialog as changed.
private void lvMapping_AfterLabelEdit(object sender, 
             LabelEditEventArgs e)
{
  this.Changed = true;
};
}

实现对话框的详细信息可以在本文随附的源代码中找到,但这应该能让您了解基本思想。

收尾工作

我们现在有一个功能齐全的帮助系统,帮助作者可以在编译后轻松配置。本节介绍了一些附加功能,以便您了解如何扩展该系统。

处理“?”对话框帮助按钮

当您有一个带有关联 ContextID 的对话框(例如,从 FormEx 派生的对话框)时,您可能希望用户能够单击“?”帮助按钮以调出该上下文的 HTML 帮助。我们将修改 FormEx,以便所有对话框都具有此行为。在 FormEx 设计器中设置以下属性以使帮助按钮出现:

HelpButton = true;
MaximizeBox = false;
MinimizeBox = false;

现在我们需要重写 OnHelpButtonClicked() 方法以调用我们的帮助处理程序并取消更改光标的默认行为。

protected override void OnHelpButtonClicked(CancelEventArgs e)
{
  HelpUtility.ProcessHelpRequest(this);
  base.OnHelpButtonClicked(e);
  e.Cancel = true;
}

我们完成了 :)。

处理选项卡控件

对于选项卡(或类似)控件,您可能需要更改帮助系统的默认行为。例如,您可能希望当选项卡获得焦点时返回的 ContextIDTabPage 上包含的控件的 ContextID。为此,我们只需覆盖包含选项卡控件的窗体的 ContextID 属性。

public override string ContextHelpID
{
  get
  {
    switch (this.tcSettings.SelectedIndex)
    {
      case 0: return settingsGeneral1.ContextHelpID;
      case 1: return settingsConfiguration1.ContextHelpID;
    }
    return base.ContextHelpID;
  }
}

源代码和示例应用程序

随附的示例应用程序包含一个完全检测过的应用程序的源代码,它阐述了本文的原理。它还包含一个帮助文件“ContextHelpMadeEasy.chm”,您可以使用它来测试应用程序。此文件应位于与构建的可执行文件相同的目录中。ContextHelpMadeEasy.chm 中的主题文件名为:

  • Configuration_Settings.htm
  • Configuration_Settings.htm
  • Context_Help_Made_Easy_Test_Application.htm
  • General_Settings.htm
  • Settings.htm
  • Topic_A.htm
  • Topic_B.htm
  • Topic_C.htm
  • Topic_D.htm
  • UI_1.htm
  • UI_2.htm
  • UI_1_Left_Side.htm
  • UI_1_Right_Side.htm
  • UI_2.htm

要查看其工作原理,请运行示例应用程序并通过“文件”和“视图”菜单导航到任何应用程序屏幕。将光标放在某个控件中,然后按住 Control 键,点击 F1。将出现帮助作者对话框,您可以使用该对话框将任何这些主题与当前上下文关联起来。

致谢

我要感谢我的雇主 Serena Software 鼓励我与 .NET 社区分享这些想法。当然,没有我的同事,我将彻底迷失。

版本历史

  • 2007 年 2 月 2 日:初始版本。
  • 2007 年 2 月 8 日:文本编辑、目录以及对 Charles Williams 的 IExtenderProvider 建议的引用。
© . All rights reserved.