Office 2010 VSTO 附加组件的自定义功能区帮助






4.59/5 (6投票s)
为 VSTO 插件的 Ribbon 控件提供自定义的上下文敏感帮助。
引言
如果您曾经编写过包含功能区的 Office 插件,您可能会注意到当您将鼠标悬停在功能区上时弹出的“按 F1 获取帮助”屏幕提示。如果您和我一样,您可能会想知道如何显示自定义帮助,并且对无法做到这一点感到有些失望。
本文介绍了影响屏幕提示的设置,以及一种可用于为自定义 VSTO 应用程序级别插件中的功能区控件显示自定义帮助的方法。
背景
VSTO 应用程序插件允许开发人员扩展 Office 应用程序。虽然每个 Office 应用程序的模型各不相同,但功能区等控件的核心实现是相同的。
功能区控件的一个关键特性是屏幕提示,它允许您输入每个控件功能描述。对于自定义插件,屏幕提示的底部显示一个齿轮图标、您的友好插件名称以及“按 F1 获取插件帮助”消息。

您可以通过“文件...选项...常规”设置来控制是否显示屏幕提示及其超级提示。
对于任何自定义插件,按 F1 会打开通用的“在 Office 程序中查看、管理和安装插件”帮助主题。本文的目的是展示一种改变这种行为以显示自定义帮助的方法。
Office 应用程序使用 Microsoft Active Accessibility (MSAA) 将 UI 信息暴露给外部世界。MSAA 的使用是本文代码实现的核心。有关此主题的介绍,请参阅 Code Project 文章 使用 Microsoft Active Accessibility (MSAA) 进行 UI 自动化。
使用代码
当用户按下 F1 键并且功能区处于自定义帮助的上下文中时,RibbonHelpContext
会引发 HelpActivated
事件。该事件使用 HelpActivatedEventArgs
类定义上下文内容。
您需要使用 IHelpCollection
的实例来定义自定义帮助的内容,该实例公开了一个 RibbonHelpItem
项的字典。这允许上下文知道它为哪些控件提供自定义帮助(代码的一个不希望有的副作用是,您可以为功能区的任何部分提供帮助)。
RibbonHelpItem
描述了两个属性:Key
和 HelpId
。Key
描述控件的路径,是一个以冒号分隔的层次结构中 ID 的列表。HelpId
就是一个标识符,您可以使用它来激活所需的帮助行为。
在我的示例中,我通过序列化 XML 文件实现了 IHelpCollection
。
<?xml version="1.0" encoding="utf-8"?>
<Help xmlns="http://addin-help">
<!-- Repeat this for each ribbon in your add-in -->
<AddInHelp key="OfficeHelpSampleRibbon">
<!-- An entry for each control you want to provide custon help for -->
<HelpItem key="OfficeHelpSampleRibbon:group1:button1" helpId="1001" />
</AddInHelp>
</Help>
要使用 RibbonHelpContext
,请挂钩插件的启动和关闭事件以初始化和处置 RibbonHelpContext
的实例。在关闭事件中调用 Dispose()
以确保正确移除 Windows API 挂钩尤其重要。
//...
using RibbonHelp.Core;
//...
namespace AddIns.Namespace
{
public partial class ThisAddIn
{
private RibbonHelpContext ribbonHelp;
private void ThisAddIn_Startup(object sender, System.EventArgs e)
{
// get an instance of IHelpCollection from somewhere...
var hc = HelpCollection.FromXmlString(Resources.Help);
// initialize the local instance
ribbonHelp = new RibbonHelpContext(hc);
// decide what you want to do when the help is activated...
ribbonHelp.HelpActivated += (o, args) => Debug.WriteLine(args.HelpItem.HelpId);
}
private void ThisAddIn_Shutdown(object sender, System.EventArgs e)
{
// this bit is important to ensure the api hooks are removed correctly
if (ribbonHelp != null) ribbonHelp.Dispose();
}
//...
}
}
运行示例
示例解决方案包含 Excel、Word、PowerPoint 和 Outlook 2010 的应用程序级别插件。每个示例都有一个功能区,并使用如上所述的 RibbonHelpContext
实例。
在调试模式下运行任何示例时,您可以使用跟踪窗格来概览当前上下文。您可以通过单击每个功能区示例左上角的“切换跟踪窗格”按钮来显示/隐藏跟踪窗格。

在调试模式下运行任何示例时,如果控件上下文标签为绿色,则按 F1 将会显示自定义帮助主题。
实现
大部分工作由两个类完成:RibbonShowHideContext
和 RibbonFocusContext
。这两个类都使用 SetWinEventHook
挂钩 MSAA 事件,并使用 AccessibleObjectFrom
* P/Invoke API 调用。这两个类都继承自 RibbonContextBase
,它负责挂钩事件。
//..
public abstract class RibbonContextBase : IRibbonContext
{
protected IntPtr Hook { get; private set; }
protected IHelpItems HelpCollection { get; private set; }
private GCHandle hookDelegateHandle;
private readonly WinEventProc hookDelegate;
internal IWinApi Win32;
internal RibbonContextBase(IHelpItems helpCollection, IWinApi win32,
WinEvent eventMin, WinEvent eventMax)
{
if (helpCollection == null)
throw new ArgumentNullException("helpCollection");
if (win32 == null)
throw new ArgumentNullException("win32");
this.HelpCollection = helpCollection;
this.Win32 = win32;
this.hookDelegate = Callback;
this.hookDelegateHandle = GCHandle.Alloc(this.hookDelegate);
this.Hook = this.Win32.SetWinEventHook(
(uint)eventMin,
(uint)eventMax,
IntPtr.Zero, hookDelegate, 0, (uint)AppDomain.GetCurrentThreadId(), 0);
}
//..
internal abstract void Callback(IntPtr hWinEventHook, WinEvent eventType,
IntPtr hwnd, uint idObject,
uint idChild, uint dwEventThread,
uint dwmsEventTime);
//..
}
RibbonShowHideContext
和 RibbonFocusContext
的组合允许跟踪鼠标和键盘交互。
RibbonShowHideContext
RibbonShowHideContext
跟踪两个 MSAA 事件:EVENT_OBJECT_SHOW
和 EVENT_OBJECT_HIDE
。它不跟踪每一个 MSAA 显示和隐藏事件,因为事件太多,效率低下。
为防止这种情况,RibbonShowHideContext
使用 MouseContext
实例,这是一个简单的 Windows 挂钩实现,使用 SetWindowsHookEx
API 调用来跟踪 WM_MOUSEMOVE
消息。每当鼠标位于功能区上下文中时,ribbonshowhidecontext
将处理消息并设置任何所需的上下文。它使用 AccessibleObjectFromPoint
P/Invoke API 来确定当前上下文中的控件。
//..
public class RibbonShowHideContext : RibbonContextBase
{
//..
internal override void Callback(IntPtr hWinEventHook, WinEvent eventType, IntPtr hwnd,
uint idObject, uint idChild, uint dwEventThread, uint dwmsEventTime)
{
//-> don't do anything if we're not in the ribbon
if (!this.mouseInContext) return;
//..
Point cursorPosition;
if (Win32.GetCursorPos(out cursorPosition) == 0) return;
IAccessible accFromPoint;
object childFromPoint;
Win32.AccessibleObjectFromPoint(cursorPosition, out accFromPoint, out childFromPoint);
//..
}
//..
}
RibbonFocusContext
RibbonFocusContext
跟踪一个 MSAA 事件:EVENT_OBJECT_FOCUS
。它处理消息并设置任何所需的上下文。与 RibbonShowHideContext
不同,它会处理每一个 EVENT_OBJECT_FOCUS
消息。它使用 AccessibleObjectFromEvent
P/Invoke API 来确定当前上下文中的控件。
//..
public class RibbonFocusContext : RibbonContextBase
{
//..
internal override void Callback(IntPtr hWinEventHook, WinEvent eventType, IntPtr hwnd,
uint idObject, uint idChild, uint dwEventThread, uint dwmsEventTime)
{
//..
IAccessible accEventObject;
object child;
if(Win32.AccessibleObjectFromEvent(hwnd, idObject,
idChild, out accEventObject, out child) != 0x0)
return;
//..
}
//..
}
这两个类实际上都在导航控件的父层次结构/路径,然后检查该路径是否在提供的 IHelpCollection
中。
路径是通过导航从相关 AccessibleObjectFrom*
P/Invoke 函数发现的对象所拥有的 IAccessible
接口找到的。
//..RibbonContextBase
private static readonly string[] RootList =
new[] { RibbonConstants.RibbonLower, RibbonConstants.Ribbon, RibbonConstants.RibbonTabs };
private List<string> GetAccessiblePathContext(IAccessible accessibleObj)
{
var contextName = accessibleObj.accName[0];
var lastName = this.ContextControl;
var newContextPathList = new List<string>();
if (!string.IsNullOrEmpty(contextName))
{
//-> Don't repeat
if (contextName == this.ContextControl) return this.contextPathList;
lastName = contextName;
newContextPathList.Add(contextName);
}
IAccessible accParent = accessibleObj.accParent;
while (accParent != null)
{
contextName = accParent.accName[0];
if (contextName.IsNullOrEmpty())
{
accParent = accParent.accParent;
continue;
}
//-> For when the Ribbon is collapsed
if (contextName == RibbonConstants.RibbonTabs && newContextPathList.Count == 1)
{
var stateUint = Convert.ToUInt32(accessibleObj.accState[0]);
var selectable = AccessibleState.HasState(stateUint,
Win.AccessibleStates.STATE_SYSTEM_SELECTABLE);
if (selectable)
{
newContextPathList.Insert(0, "*Tab*");
break;
}
}
//-> once we hit any Ribbon root container we're not interested in any more hierarchy
if (contextName.EqualsAny(StringComparison.OrdinalIgnoreCase, RootList))
break;
if (contextName != lastName)
{
newContextPathList.Add(contextName);
lastName = contextName;
}
accParent = accParent.accParent;
}
newContextPathList.Reverse();
return newContextPathList;
}
关注点
屏幕提示样式设置和行为
使用 MSAA 事件跟踪上下文的一个有趣副作用是,其行为几乎自然地反映了屏幕提示样式选项的设置。有三种设置,并且这些设置对不能接受键盘焦点的功能区控件有一种细微的影响。只有当选中了某个项时,功能区控件才能准备好接受键盘焦点。

除了“不显示屏幕提示”设置之外,当您在任何功能区项的上下文中按 F1 时,您将被导向“在 Office 程序中查看、管理和安装插件”帮助主题。
当“不显示屏幕提示”开启时,尚未获得键盘焦点的功能区控件将引导您进入 Office 帮助索引。
自定义帮助提供程序的行为最终工作方式非常相似。当功能区获得键盘焦点时,您将被导向自定义帮助主题;否则,您将被导向 Office 帮助索引。
一个例外似乎是 RibbonComboBox
。RibbonComboBox
中的项不会引发 EVENT_OBJECT_FOCUS
事件,因此只能由 RibbonShowHideContext
跟踪,而这又依赖于屏幕提示行为。
考虑到我们使用显示和隐藏事件来跟踪上下文,如果没有任何内容显示,我们就没有什么可以跟踪的,这种行为是有道理的。它还表明,功能区控件只有在准备好接受键盘焦点后才会引发 EVENT_OBJECT_FOCUS
事件——正如其文档化行为所述。
这有点太粗暴了
这可能很明显,但以这种方式跟踪功能区并覆盖 F1 键意味着您可以改变整个功能区的帮助行为。虽然这可能听起来像是一个不错的副作用,但我实际上对此感到有点不安。
在实现键盘挂钩时,有一些建议遵循的规则,其中之一是始终使用 CallNextHookEx
将调用传递给链中的下一个处理程序。为了在我们的自定义帮助上下文中阻止默认的 Excel 帮助打开,在激活自定义帮助时不会传递该调用。
这可能会导致依赖 F1 键的其他进程无法正常工作。此外,我们无法确定是否有其他进程阻止我们的行为正常工作。我只在具有 Office 2010 的开发人员 Windows 7 工作站上测试过此代码。我相信还有一些安全和环境方面的考虑因素未被考虑在内。
我希望不必考虑这些
最终,我想编写外观专业、感觉也专业的插件。我希望有一种更好、有文档记录、内置的方法来做到这一点。这可能只是我个人的想法,但我认为开发人员希望有某种方式提供和集成自定义帮助,“按 F1 获取插件帮助”屏幕提示对开发人员和最终用户来说既不友好也不直观。
历史
First version.