在基于 MSHTML 的应用程序中实现吸附到网格






4.80/5 (19投票s)
如何在基于 MSHTML 的应用程序中实现吸附到网格和绘制网格。
引言
我当前项目的一部分要求能够编辑自定义 HTML 文档。我正在通过CHtmlEditView
在编辑模式下使用 MSHTML(Microsoft Internet Explorer 的核心组件)。虽然它有它的问题,但 MSHTML 有几个压倒性的优势。它是免费的,并且几乎可以假定它存在于你遇到的每一台 Windows 计算机上。它也不是一个非常难以上手的东西。我正在处理的文档包含图片和 LABEL
控件,所有这些都采用绝对定位。编辑操作基本上就是替换样板文字和图片,并在屏幕上移动它们。一个很好的功能是吸附到网格,以便所有内容都能轻松对齐,并且有网格的视觉表示。
现有技术
在 MSDN 上搜索“snap to grid”(吸附到网格)可以找到 MSHTML 的代码下载(URL 不包含,因为它们会改变)和一个名为EditHost.exe
的示例文件,它是一个自解压可执行文件。该示例包含一个实现我所需功能的 MSHTML 主机的源代码。然而,该示例是一个非 MFC 的 C++ 应用程序,并且感兴趣的部分被封装在一堆 ATL 类中。因为我的应用程序是一个使用文档/视图体系结构的 MFC SDI 应用程序,所以我决定使用 MFC 重新实现他们的解决方案。我选择不尝试使用他们提供的 ATL 类,原因是我在 MFC 文档/视图体系结构中施加的一些要求。但这并没有阻止我窃取他们的一些代码 :)
基础
我想要实现的功能分为两部分。第一部分是吸附到网格。这基本上需要能够截获在屏幕上移动或调整元素大小的尝试,接收坐标并修改这些坐标以强制执行“吸附”粒度。第二部分是在 MSHTML 显示表面上绘制网格的功能。让我们依次考虑这两个部分。
实现吸附到网格
MSHTML 在 5.5 版本中引入了IHTMLEditHost
COM 接口,专门用于支持吸附到网格。你在 Platform SDK 附带的标准库中找不到这个接口的实现 - 你需要自己实现它。除了 IUnknown
中的标准 3 个方法(我不再提及这 3 个标准方法)之外,它还有一个方法,SnapRect()
,每当你尝试移动或调整当前正在编辑的文档中的任何内容时,MSHTML 都会调用它。参数是所选元素的 IHTMLElement
接口、元素的新矩形(屏幕坐标)和一个指定正在使用的拖动手柄的参数。元素接口很有用,因为你可以用它来查询所选对象的 HTML 属性。例如,你可能希望能够指定某个对象被锁定在原位。你可以在对象上设置一个属性来指定这一点。SnapRect
方法可以查询属性,如果设置了该属性,则将元素强制回其原始位置和大小。我在我的应用程序中使用了这个功能,但代码没有在此处提供,因为我不想陷入大量支持代码的泥潭。
新矩形是指向包含对象新坐标的 RECT
的指针。如果你在 SnapRect()
方法中更改了坐标,对象就会移动以匹配你的更改。
拖动手柄用于决定 RECT
中的哪些坐标(如果有)应该被修改以强制“吸附到网格”。
我在上面说过,你需要实现 IHTMLEditHost
,这引出了一个问题:MSHTML 如何获得你实现的接口?它通过一个两步过程获得接口。第一步是在主机(你的应用程序)上请求 IServiceProvider
接口。如果它获得了该接口,它就会通过调用 IServiceProvider::QueryService()
来请求 IHTMLEditHost
接口。
告诉 MSHTML 使用你的 IHTMLEditHost 实现
省略很多细节,CHtmlEditView
和 CHtmlView
类实现了一个自定义的 OLE 控件站点。这两个类都托管 MSHTML 并提供一个“父”接口,MSHTML 可以使用该接口来查询感兴趣的接口。处理自定义 OLE 控件站点的标准类称为 CHtmlControlSite
。该类仅实现 IDocHostUIHandler
接口,MSHTML 使用该接口来确定是否应显示滚动条等。 (请参阅我在此处的文章,了解有关 CHtmlControlSite
的讨论)[^]
我们不能从 CHtmlControlSite
派生新类,原因有两个。第一个原因是类定义不在头文件中,而是在 viewhtml.cpp
中。更重要的是,如果我们尝试从 CHtmlControlSite
派生类,MFC COM 接口宏[^] 就会给我们带来麻烦。我们唯一的选择是重新实现该类,我们将其实现为 CHtmlEditControlSite
类。
在以下代码中,我没有提供类的完整定义。我们将逐一讲解。当然,完整的类定义包含在下载文件中。我们替换的 CHtmlEditControlSite
类一开始看起来是这样的。
class CHTMLEditControlSite : public COleControlSite
{
public:
CHTMLEditControlSite(COleControlContainer* pParentWnd);
CHtmlView *GetView() const;
protected:
// Implementation
DECLARE_INTERFACE_MAP()
// This is the implementation of the IDocHostUIHandler interface
// MSHMTL gets this interface from us so we have to reference count it.
BEGIN_INTERFACE_PART(DocHostUIHandler, IDocHostUIHandler)
STDMETHOD(ShowContextMenu)(DWORD, LPPOINT, LPUNKNOWN, LPDISPATCH);
STDMETHOD(GetHostInfo)(DOCHOSTUIINFO*);
STDMETHOD(ShowUI)(DWORD, LPOLEINPLACEACTIVEOBJECT, LPOLECOMMANDTARGET,
LPOLEINPLACEFRAME, LPOLEINPLACEUIWINDOW);
STDMETHOD(HideUI)(void);
STDMETHOD(UpdateUI)(void);
STDMETHOD(EnableModeless)(BOOL);
STDMETHOD(OnDocWindowActivate)(BOOL);
STDMETHOD(OnFrameWindowActivate)(BOOL);
STDMETHOD(ResizeBorder)(LPCRECT, LPOLEINPLACEUIWINDOW, BOOL);
STDMETHOD(TranslateAccelerator)(LPMSG, const GUID*, DWORD);
STDMETHOD(GetOptionKeyPath)(OLECHAR **, DWORD);
STDMETHOD(GetDropTarget)(LPDROPTARGET, LPDROPTARGET*);
STDMETHOD(GetExternal)(LPDISPATCH*);
STDMETHOD(TranslateUrl)(DWORD, OLECHAR*, OLECHAR **);
STDMETHOD(FilterDataObject)(LPDATAOBJECT , LPDATAOBJECT*);
END_INTERFACE_PART(DocHostUIHandler)
// This is the implementation of the IServiceProvider interface
// MSHMTL gets this interface from us so we have to reference count it.
BEGIN_INTERFACE_PART(ServiceProvider, IServiceProvider)
STDMETHOD(QueryService)(REFGUID, REFIID, void **);
END_INTERFACE_PART(ServiceProvider)
// This is the implementation of the IHTMLEditHost interface
// MSHMTL gets this interface from us so we have to reference count it.
BEGIN_INTERFACE_PART(HTMLEditHost, IHTMLEditHost)
STDMETHOD(SnapRect)(IHTMLElement *pIElement, RECT *prcNew,
ELEMENT_CORNER eHandle);
XHTMLEditHost();
int m_iSnap;
END_INTERFACE_PART(HTMLEditHost)
};
DocHostUIHandler
部分是从 MFC 的 CHtmlControlSite
实现中复制的。它几乎将所有内容委托给视图类的虚拟函数,该视图类直接或间接继承自 CHtmlView
。ServiceProvider
是我们对 IServiceProvider
接口的实现。回想一下,MSHTML 调用此接口来请求 IHTMLEditHost
接口。如果尝试获取 IServiceProvider
接口或 IHTMLEditHost
接口失败,它不会介意,但如果尝试获取 IHTMLEditHost
接口成功,MSHTML 将根据需要调用 IHTMLEditHost::SnapRect()
。
我们对 ServiceProvider::QueryService()
的实现如下所示。
STDMETHODIMP CHTMLEditControlSite::XServiceProvider::QueryService(REFGUID guidService,
REFIID riid,
void **ppObj)
{
METHOD_PROLOGUE_EX_(CHTMLEditControlSite, ServiceProvider)
HRESULT hr = E_NOINTERFACE;
*ppObj = NULL;
if (guidService == SID_SHTMLEditHost && riid == IID_IHTMLEditHost)
{
*ppObj = (void **) &pThis->m_xHTMLEditHost;
hr = S_OK;
}
return hr;
}
这会检查请求的服务是否为 MSHTML IEditHost
接口。如果是,则返回指向我们 IHTMLEditHost
实现的指针。我们上面的 IHTMLEditHost
声明已经显示过了。构造函数 XHTMLEditHost()
初始化为 8 像素边界吸附。接口的真正核心在于我们对 SnapRect()
的实现,如下所示。STDMETHODIMP CHTMLEditControlSite::XHTMLEditHost::SnapRect(
IHTMLElement * /*pIElement*/,
RECT * prcNew,
ELEMENT_CORNER eHandle)
{
if (GetAsyncKeyState(VK_CONTROL) & 0x10000000)
// If the control key is down return (no snap).
return S_OK;
LONG lWidth = prcNew->right - prcNew->left;
LONG lHeight = prcNew->bottom - prcNew->top;
switch (eHandle)
{
case ELEMENT_CORNER_NONE:
prcNew->top = ((prcNew->top + (m_iSnap / 2)) / m_iSnap) * m_iSnap;
prcNew->left = ((prcNew->left + (m_iSnap / 2)) / m_iSnap) * m_iSnap;
prcNew->bottom = prcNew->top + lHeight;
prcNew->right = prcNew->left + lWidth;
break;
// Other cases
.
.
.
}
它执行适当的算术运算,将 prcNew
矩形强制吸附到吸附边界,具体取决于选择了哪个调整大小手柄。GetAsyncKeyState(VK_CONTROL) & 0x10000000
测试控件键(任一键)是否被按下。如果是,它会立即退出,允许用户通过在绘图表面上拖动对象时按住控件键来覆盖我们的吸附到网格功能。绘制网格
这有点难。这次 MSHTML 不会任意要求我们提供一个接口,而是我们必须在适当的时候向 MSHTML 注册一个元素行为(behavior),然后请求或提供必要的接口。适当的时间当然是我们的文档加载完成后。当然,虽然你可以获取任何 MSHTML 窗口的设备上下文句柄并在其上绘制,但你真正想要的是在 MSHTML 渲染其余显示之前绘制网格。最终用户真的想要你的网格线绘制在他们的内容之上吗?实现正确的绘制顺序需要与 MSHTML 进行一些“舞蹈”。
元素行为
元素行为(Element behaviors)已添加到 Microsoft Internet Explorer 5 版本中。它们提供了一个“钩子”,可用于修改特定元素在 HTML 页面中的行为方式。行为可以有很多种,但我们感兴趣的是元素如何渲染。只要元素能返回IHTMLElement2
接口,你就可以在页面中的任何元素上指定元素行为。我们通过创建一个实现 IElementBehaviorFactory
接口的对象,并将其地址传递给 IHTMLElement2::addBehavior
函数来注册元素行为。然后,MSHTML 在渲染文档时,会调用行为工厂,传递大量参数,指定它为该特定元素(由该元素指定)想要哪种行为,并且仅适用于在 HTML 中附加了行为的元素或那些对其调用了 addBehavior
的元素。然后,元素行为工厂返回一个指向实现该行为的对象的 IElementBehavior
接口。考虑到我们想在整个文档的背景上绘制网格,一个明显的起点是文档接口本身 IHTMLDocument2
。不幸的是,这不起作用,因为文档本身不是一个元素,它是一个元素容器。我们需要向下深入一级,获取文档 body 的接口。即使它是 body,它仍然是 IHTMLElement2
接口,这意味着我们可以更深入地在页面上绘制单个元素的网格,如果你愿意的话。
一旦我们获得了文档 body 元素的指针,我们就向它添加我们的行为工厂。稍后,MSHTML 会调用我们的行为工厂,请求 IElementBehavior
接口。我们乖乖地返回一个。然后 MSHTML 调用我们元素行为对象的 IElementBehavior::Init()
函数,传递一个指向 IElementBehaviorSite
接口的指针。然后我们的应用程序会在 IElementBehaviorSite
上调用 QueryInterface()
,请求 IHTMLPaintSite
接口。一旦我们获得 IHTMLPaintSite
接口,我们就会使它代表的矩形失效,由于我们是在 HTML 文档的 body 上请求的,这意味着我们使整个 MSHTML 显示表面失效。MSHTML 会通过重绘显示表面来响应,并且在此过程中,它会请求一个 IHTMLPainter 接口并调用其
Draw()
方法,这就是我们绘制网格的地方。呼!
也许一张图会有帮助
网格代码
我不会在文章中重复 BEGIN_INTERFACE_PART
/END_INTERFACE_PART
宏的代码块。我假设你理解 MFC COM 接口宏的工作原理,并继续介绍感兴趣的代码。让我们首先看看初始化整个过程的代码,安装网格处理程序的代码。这是外部类 CHTMLEditControlSite
的一部分,并由我们的应用程序调用。
void CHTMLEditControlSite::InstallGrid(IHTMLDocument2 *pDoc)
{
HRESULT hr;
IHTMLElement *pBody = NULL;
IHTMLElement2 *pBody2;
VARIANT vFactory;
if (pDoc == (IHTMLDocument2 *) NULL)
return;
// Get IHTMLElement and IHTMLElement2 interfaces for the body
hr = pDoc->get_body(&pBody);
if (pBody == (IHTMLElement *) NULL)
return;
hr = pBody->QueryInterface(IID_IHTMLElement2, (void **) &pBody2);
if (pBody2 == (IHTMLElement2 *) NULL)
{
pBody->Release();
return;
}
if (m_gridCookie)
{
VARIANT_BOOL dummy;
hr = pBody2->removeBehavior(m_gridCookie, &dummy);
m_gridCookie = NULL;
}
// Convert the grid factory pointer to the proper VARIANT data type
// for IHTMLElement2::AddBehavior
V_VT(&vFactory) = VT_UNKNOWN;
V_UNKNOWN(&vFactory) = &m_xHTMLElementBehaviorFactory;
// Add Grid behavior
hr = pBody2->addBehavior(NULL, &vFactory, &m_gridCookie);
// Release resources
hr = pBody->Release();
hr = pBody2->Release();
return;
}
这首先通过获取文档 body 的 IHTMLElement
接口来开始。一旦我们获得了它,我们就获取一个 IHTMLElement2
接口。当我们获得 IHTMLElement2
接口后,我们就调用它上面的 addBehavior
,传递指向我们元素行为工厂的指针。addBehavior
返回一个 cookie,我们稍后需要它来移除行为。直到 MSHTML 从我们这里请求了一堆其他接口之前,并没有太多有趣的事情发生。我们的行为工厂被调用,我们返回指向我们 IElementBehavior
接口的指针。然后 MSHTML 调用我们的 IElementBehavior::Init()
方法,如下所示。
STDMETHODIMP CHTMLEditControlSite::XHTMLElementBehavior::Init(
IElementBehaviorSite *pBehaviorSite)
{
HRESULT hr = pBehaviorSite->QueryInterface(IID_IHTMLPaintSite,
(void **) &m_spPaintSite);
if (m_spPaintSite != (IHTMLPaintSite *) NULL)
m_spPaintSite->InvalidateRect(NULL);
return hr;
}
该方法接收一个 IElementBehaviorSite
接口指针。这并非巧合,它代表了文档中的 body 对象(因为我们在注册行为工厂时使用了 body 接口)。我们通过行为站点接口获取一个指向 IHTMLPaintSite
接口的指针。一旦我们获得了它,我们就可以使显示表面失效,并根据需要强制重绘。与此同时,MSHTML 向我们查询 IHTMLPainter
接口。它需要知道的一件事是我们的 Z 顺序。我们是应该先被调用,以便 MSHTML 可以在我们绘制的内容之上绘制其他内容,还是我们最后绘制?因此,MSHTML 调用我们的 IHTMLPainter::GetPainterInfo()
方法,如下所示。
STDMETHODIMP CHTMLEditControlSite::XHTMLPainter::GetPainterInfo(
HTML_PAINTER_INFO *pInfo)
{
if (pInfo == NULL)
return E_POINTER;
pInfo->lFlags = HTMLPAINTER_TRANSPARENT;
pInfo->lZOrder = HTMLPAINT_ZORDER_BELOW_CONTENT;
memset(&pInfo->iidDrawObject, 0, sizeof(IID));
pInfo->rcExpand.left = 0;
pInfo->rcExpand.right = 0;
pInfo->rcExpand.top = 0;
pInfo->rcExpand.bottom = 0;
return S_OK;
}
这告诉 MSHTML 先调用我们。现在是绘制时间了。这段代码很简单。MSHTML 调用我们的 IHTMLPainter::Draw()
方法,并提供我们应该在其上绘制的设备上下文。我们所要做的就是绘制我们的网格。
STDMETHODIMP CHTMLEditControlSite::XHTMLPainter::Draw(
RECT rcBounds,
RECT /*rcUpdate*/,
LONG /*lDrawFlags*/,
HDC hdc,
LPVOID /*pvDrawObject*/)
{
if (m_bGrid != FALSE)
{
HPEN redPen = (HPEN) CreatePen(PS_DOT, 0, RGB(0xff, 0x99, 0x99));
HPEN oldPen = (HPEN) SelectObject(hdc, redPen);
long lFirstLine = rcBounds.left + m_iGrid;
for (int i = lFirstLine; i <= rcBounds.right; i += m_iGrid)
{
MoveToEx(hdc, i, rcBounds.top, NULL);
LineTo(hdc, i, rcBounds.bottom);
}
lFirstLine = rcBounds.top + m_iGrid;
for (i = lFirstLine ; i <= rcBounds.bottom; i += m_iGrid)
{
MoveToEx(hdc, rcBounds.left, i, NULL);
LineTo(hdc, rcBounds.right, i);
}
SelectObject(hdc, oldPen);
DeleteObject(redPen);
}
return S_OK;
}
使用代码
在你的CHTMLEditView
派生视图类的头文件中,你需要添加这个函数原型。它在 MSDN 中没有文档记录,但幸运的是,它是一个 public virtual
函数。virtual BOOL CreateControlSite(COleControlContainer* pContainer,
COleControlSite** ppSite,
UINT nID, REFCLSID clsid);
并在视图实现文件的头文件中添加一个 CHTMLEditControlSite
成员变量。我称之为 m_pEditSite
。将此函数添加到你的视图实现文件中。BOOL CMyHTMLEditView::CreateControlSit(
COleControlContainer* pContainer,
COleControlSite** ppSite, UINT /* nID */,
REFCLSID /* clsid */)
{
ASSERT(ppSite != NULL);
*ppSite = m_pEditSite = new CHTMLEditControlSite(pContainer);
return TRUE;
}
在你的视图类的一个合适的位置(也许是 OnDownloadComplete()
),添加一个对 m_pEditSite->InstallGrid()
的调用。void CMyHTMLEditView::OnDownloadComplete()
{
CHtmlEditView::OnDownloadComplete();
// other code you need...
m_pDoc = (IHTMLDocument2 *) GetHtmlDocument();
m_pEditSite->InstallGrid(m_pDoc);
m_pEditSite->Grid(TRUE);
}
一旦为该文档安装了网格,你就可以通过调用 CHTMLEditControlSite::Grid()
传递 TRUE
或 FALSE
来打开或关闭它。如果你导航到另一个文档,你必须再次调用 CHTMLEditControlSite::InstallGrid()
来重新安装网格处理程序。
AddRef() 和 Release() 说明
如果你查看源代码,你可能会注意到在某些地方我正确地处理了 AddRef()
和 Release()
,而在其他地方我没有。这不是我的懒惰或缺乏知识。事实是,除非我严重误解了 COM 引用计数,否则 MSHTML 似乎并不完全遵循引用计数规则。有时它会调用 Release()
释放我们给它的接口指针,有时则不会。我们的 CHTMLEditControlSite
类继承自 CCmdTarget
,它代表我们实现了引用计数。正如我在上一篇文章 MFC COM Interface Macros[^] 中讨论过的,CCmdTarget
的析构函数(在调试构建中)断言引用计数器小于或等于 1。我通过反复试验发现哪些接口应该实现正确的引用计数,哪些不应该。实际上,由于我们的类嵌入在视图类中,并且直到视图类消失才会消失,因此我们没有正确实现 COM 引用计数并不重要。有人可能会争辩说,在这种情况下,我们类中的任何接口都不需要实现正确的引用计数,但我更愿意在可能的情况下以正确的方式去做。
关于演示程序的说明
演示程序使用了对 HTML 页面的硬编码引用,而该 HTML 页面又对图像文件有硬编码引用。你可能需要调整 HTML 页面的引用或 HTML 页面中的图像引用。历史
2004 年 4 月 25 日 - 初始版本。