在Windowless RichEdit中插入OLE对象

RichEdit支持在文本中插入OLE对象,OLE对象中可以显示任意自定义的内容,从而达到丰富的显示效果,例如文字和图片在同一行中混合显示。本文在《实现一个可编辑的Windowless RichEdit》一本的基础上,介绍如何在Windowless RichEdit中插入OLE对象。完整的示例代码可以在 WindowlessRichEdit-Example 获得。

实现自定义的OLE对象

在插入OLE对象之前,我们首先要实现一个自定义的OLE对象。这个对象必须实现以下两个COM接口:

  • IOleObject,所有OLE对象都需要实现的基础接口。
  • IViewObject,用于显示OLE对象的接口。

IOleObject 有不少虚函数需要实现,但其实所有函数都可以不实现,全都返回 E_NOTIMPL 也是可以的。在本文的例子中,OLE对象仅用于显示,没有任何行为,所以对于IOleObject的所有函数都返回了 E_NOTIMPL

IViewObject 只有一个 Draw() 函数需要实现,用来绘制OLE对象的内容。其它函数都可以不实现,返回 E_NOTIMPL 即可。 Draw() 的参数列表比较长:

1
2
3
4
5
6
7
8
9
10
11
12
HRESULT Draw(
DWORD dwDrawAspect,
LONG lindex,
void* pvAspect,
DVTARGETDEVICE* ptd,
HDC hdcTargetDev,
HDC hdcDraw,
LPCRECTL lprcBounds,
LPCRECTL lprcWBounds,
BOOL(*pfnContinue)(ULONG_PTR dwContinue),
ULONG_PTR dwContinue
);

大部分参数用于复杂的OLE对象或者特殊的绘制场景(例如打印机),对于简单的OLE对象,通常只需要关注 hdcDrawlprcBounds 这两个参数就可以了。hdcDraw 表示用于绘制的设备句柄, lprcBounds 表示OLE对象在 hdcDraw 中的绘制区域,我们只能在这个区域中进行绘制。

在这个示例的OLE对象中,我们只是简单地将OLE对象填充成蓝色,以显示它的存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyOLEObject : public IOleObject, public IViewObject {
public:
HRESULT Draw(
DWORD dwDrawAspect,
LONG lindex,
void* pvAspect,
DVTARGETDEVICE* ptd,
HDC hdcTargetDev,
HDC hdcDraw,
LPCRECTL lprcBounds,
LPCRECTL lprcWBounds,
BOOL(*pfnContinue)(ULONG_PTR dwContinue),
ULONG_PTR dwContinue) override {

HBRUSH brush = CreateSolidBrush(RGB(0xaa, 0xcc, 0xee));
FillRect(hdcDraw, reinterpret_cast<const RECT*>(lprcBounds), brush);
DeleteObject(brush);
return S_OK;
}

//其它函数实现...
};

注意, lprcBounds 的类型是 RECTL 指针,而 FillRect 的参数类型是 RECT 指针,虽然两者的类型不一样,但内存布局实际上是一样的,所以可以直接将 lprcBounds 转成 RECT 指针。

此外,我们要给这个OLE对象定义一个类ID,在插入的时候要用到:

1
2
3
4
5
6
constexpr GUID CLSID_MyOLEObject = { 
0xe16f8acd,
0x5b3a,
0x4167,
{ 0xa4, 0x49, 0xdc, 0x57, 0xd, 0xd4, 0x44, 0x59 }
};

插入OLE对象

接下来,就可以往RichEdit中插入这个OLE对象了。插入OLE对象大致上需要两步:

  • 给RichEdit发送 EM_GETOLEINTERFACE 消息,获取 IRichEditOle 接口,这个接口提供了一系列用于操作OLE对象的函数。
  • 调用 IRichEditOleInsertObject() 函数,插入OLE对象。

InsertObject 的参数是一个 REOBJECT 结构,我们需要填充这个结构的字段:

  • cbStruct ,结构的大小,填 sizeof(REOBJECT) 即可。
  • cp ,OLE对象的插入位置,填0插入到文本第一个字符的位置,填1插入到第二字符的位置,以此类推。可以填常量 REO_CP_SELECTION ,表示插入到当前光标选中的位置。
  • clsid ,OLE对象的类ID,填上文中定义的 CLSID_MyOLEObject
  • poleobj ,要插入的OLE对象的指针。注意,OLE对象插入后,RichEdit会调用 AddRef() 来增加它的引用计数,所以在插入之后我们要调用 Release() 来减少引用计数,避免内存泄露。
  • pstgIStorage 接口的指针,一般不需要设置,传 nullptr 即可。
  • polesiteIOleClientSite 接口的指针,用于OLE对象与其所在的容器进行交互。调用 IRichEditOleGetClientSite() 函数获取这个指针并传入即可。
  • sizel,OLE对象的大小,注意这个大小是以HIMETRIC为单位,即0.01毫米。可以使用 AtlPixelToHiMetric() 函数将像素大小转换成HIMETRIC大小。
  • dvaspect ,使用哪个“外表”来绘制OLE对象,一个OLE对象可能有多个外表,例如缩略图、图标等。普通的OLE对象通常只有一个默认的外表,填 DVASPECT_CONTENT 即可。
  • dwFlags,OLE对象的一些属性设置。常用的有两个:
    • REO_BELOWBASELINE 表示OLE对象的底部与文字的底部对齐。如果没有这个设置,OLE对象的底部会与文字基线对齐,这会导致OLE对象的位置偏上。
    • REO_OWNERDRAWSELECT 表示OLE对象的选中态由自己来绘制。如果没有这个设置,OLE对象在被选中的时候,会以反色来显示(如白变黑),这通常不是我们想要的效果,所以要加上这个设置,我们自己来绘制被选中时的样式。
  • dwUser ,一个自定义的数值,跟当前插入的OLE对象绑定,一般用不到。

以下是插入OLE对象的代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//从TextService取出IRichEditOle接口
CComPtr<IRichEditOle> rich_edit_ole{};
g_text_service->TxSendMessage(EM_GETOLEINTERFACE, 0, (LPARAM)&rich_edit_ole, nullptr);

//取出IOleClientSite接口
CComPtr<IOleClientSite> client_site{};
rich_edit_ole->GetClientSite(&client_site);

//创建要插入的OLE对象
CComPtr<MyOLEObject> ole_object;
ole_object.Attach(new MyOLEObject(g_text_service));

//填充REOBJECT的字段
REOBJECT object_info{};
object_info.cbStruct = sizeof(object_info);
object_info.clsid = CLSID_MyOLEObject;
object_info.poleobj = ole_object;
object_info.polesite = client_site;
object_info.pstg = nullptr;
object_info.dvaspect = DVASPECT_CONTENT;
object_info.cp = REO_CP_SELECTION;
object_info.dwFlags = REO_BELOWBASELINE | REO_OWNERDRAWSELECT;

SIZEL size_in_pixels{};
size_in_pixels.cx = MyOLEObject::Width;
size_in_pixels.cy = MyOLEObject::Height;
AtlPixelToHiMetric(&size_in_pixels, &object_info.sizel);

//插入OLE对象
rich_edit_ole->InsertObject(&object_info);

判断OLE对象是否被选中

我们在插入OLE对象的时候指定了 REO_OWNERDRAWSELECT 设置,意味着我们需要自己来绘制OLE对象的选中态。现在,不论该OLE对象是否被选中,都显示同样的蓝色,没有区分度。所以,接下来要修改 Draw() 函数的实现,在OLE对象被选中时显示不同的颜色。

RichEdit没有任何接口或者通知可以直接告诉我们OLE对象是否被选中,所以我们只能自己去判断。判断的方法如下:

  • 给RichEdit发送 EM_EXGETSEL 消息,获取当前选中的范围。
  • 调用 IRichEditOleGetObjectCount()GetObject() 函数,遍历取出每一个OLE对象的位置,如果遍历到的OLE对象就是当前对象,再看看是否在选中的范围中。

以下是判断OLE对象是否被选中的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
bool MyOLEObject::IsSelected() const {

//取出选中的范围
CHARRANGE select_range{};
text_service_->TxSendMessage(EM_EXGETSEL, 0, reinterpret_cast<LPARAM>(&select_range), nullptr);

//没有选中范围,当前对象自然也没有被选中
if (select_range.cpMin == select_range.cpMax) {
return false;
}

CComPtr<IRichEditOle> rich_edit_ole{};
text_service_->TxSendMessage(EM_GETOLEINTERFACE, 0, (LPARAM)&rich_edit_ole, nullptr);

//遍历所有OLE对象
auto object_count = rich_edit_ole->GetObjectCount();
for (int index = 0; index < object_count; ++index) {

//取出OLE对象
REOBJECT object_info{};
object_info.cbStruct = sizeof(object_info);
HRESULT hresult = rich_edit_ole->GetObject(index, &object_info, REO_GETOBJ_POLEOBJ);
if (FAILED(hresult)) {
continue;
}

//OLE对象取出来之后需要释放它的引用计数,这里使用CComPtr来自动释放
CComPtr<IOleObject> ole_object;
ole_object.Attach(object_info.poleobj);

//找到当前OLE对象了
if (ole_object.p == this) {

//当前OLE对象的位置在选中范围内,也就意味着它被选中了
if ((select_range.cpMin == 0 && select_range.cpMax == -1) ||
(select_range.cpMin <= object_info.cp && object_info.cp < select_range.cpMax)) {

return true;
}
//在选中范围外,没被选中
else {
return false;
}
}
}

//没找到当前的OLE对象
return false;
}

IRichEditOleGetObject() 也需要传入 REOBJECT 结构,不过此时只需要设置 cbStruct 字段即可,其它字段都由该函数来填充。调用成功后, REOBJECTcp 字段表示OLE对象在文本中的位置,将其与选中范围比较即可知道它是否被选中了。如果文本被全选了, CHARRANGEcpMin 是0, cpMax 是-1,所以比较的时候要区分两种场景。

虽然 REOBJECTdwFlags 字段有一个 REO_SELECTED 的值,似乎可以用来判断OLE对象是否被选中,但这个值只有在OLE对象被单独选中的时候才会设置,如果选中的是一段文字和OLE对象,则不会设置,所以不能依赖这个值。

调用 GetObject() 的时候,最后一个参数传入 REO_GETOBJ_POLEOBJREOBJECT 中才会填充 poleobj 字段。这个指针是增加了引用计数的,所以用完之后我们要手动地减少它的引用计数。在上面的代码中,使用了 CComPtr 来自动释放。

以下是修改后的 Draw() 函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HRESULT MyOLEObject::Draw(
DWORD dwDrawAspect,
LONG lindex,
void* pvAspect,
DVTARGETDEVICE* ptd,
HDC hdcTargetDev,
HDC hdcDraw,
LPCRECTL lprcBounds,
LPCRECTL lprcWBounds,
BOOL(*pfnContinue)(ULONG_PTR dwContinue),
ULONG_PTR dwContinue) {

auto background_color = IsSelected() ? RGB(0x88, 0xaa, 0xcc) : RGB(0xaa, 0xcc, 0xee);
HBRUSH brush = CreateSolidBrush(background_color);
FillRect(hdcDraw, reinterpret_cast<const RECT*>(lprcBounds), brush);
DeleteObject(brush);
return S_OK;
}

修改鼠标光标的样式

OLE对象并不是文字,但RichEdit在某些时候仍然会将它当做文字来对待。例如,当鼠标移动到OLE对象之上时,光标仍然会显示成 I 样式,但在大部分情况下我们希望它显示成默认的箭头样式。

为了修改鼠标光标在OLE对象上的样式,我们要在处理 WM_SETCURSOR 消息,在调用 ITextServicesOnTxSetCursor() 之前(详情可参考《实现一个可编辑的Windowless RichEdit》),判断鼠标是否位于OLE对象之上。判断的方法如下:

  • 调用 IRichEditOleQueryInterface() 函数,取出它的 ITextDocument 接口。
  • 调用 ITextDocumentRangeFromPoint() 函数,根据鼠标位置取出最接近的文本,返回的是 ITextRange 接口。
  • 调用 ITextRangeGetEmbeddedObject() 函数,取出OLE对象。如果返回空指针,则说明鼠标不在OLE对象上,反之则在OLE对象上。

以下是获取鼠标位置之下的OLE对象的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
CComPtr<MyOLEObject> GetOLEObjectAtMouseCursor() {

//获取鼠标在屏幕上的位置
POINT position{};
GetCursorPos(&position);

CComPtr<IRichEditOle> rich_edit_ole;
g_text_service->TxSendMessage(EM_GETOLEINTERFACE, 0, (LPARAM)&rich_edit_ole, nullptr);

//取出ITextDocument接口
CComPtr<ITextDocument> text_document;
HRESULT hresult = rich_edit_ole->QueryInterface(IID_ITextDocument, reinterpret_cast<void**>(&text_document));
if (FAILED(hresult)) {
return nullptr;
}

//取出最接近鼠标的文本
CComPtr<ITextRange> text_range;
hresult = text_document->RangeFromPoint(position.x, position.y, &text_range);
if (FAILED(hresult)) {
return nullptr;
}

//取出文本中的OLE对象
CComPtr<IUnknown> ole_object;
hresult = text_range->GetEmbeddedObject(&ole_object);
if (FAILED(hresult)) {
return nullptr;
}

CComPtr<MyOLEObject> my_ole_object(dynamic_cast<MyOLEObject*>(ole_object.p));
return my_ole_object;
}

ITextDocumentRangeFromPoint() 函数使用的是屏幕坐标,调用 GetCursorPos() 来拿鼠标位置刚刚好。此外,RichEdit会调用 ITextHostTxScreenToClient() 函数来将屏幕坐标转换成客户区坐标,所以我们也要实现这个函数,简单地调用 ScreenToClient() 即可:

1
2
3
4
BOOL MyTextHost::TxScreenToClient(LPPOINT lppt) {
ScreenToClient(hwnd_, lppt);
return TRUE;
}

要注意,RangeFromPoint() 函数在旧版本的RichEdit中存在bug,取到的文本不准。具体表现是:当坐标位于OLE对象的后半部分时,取到的是下一个位置的文本;只有坐标位于OLE对象的前半部分时才能取到正确的文本。最新的RichEdit 4.0没有这个问题,所以建议尽可能使用新版本的RichEdit。只要在调用 LoadLibrary() 的时候指定 msftedit.dll 即可使用最新版本的RichEdit:

1
2
3
4
5
//使用最新版本的RichEdit
HMODULE module_handle = LoadLibrary(L"msftedit.dll");

//使用旧版本的RichEdit
HMODULE module_handel = LoadLibrary(L"riched20.dll");

可参考 About Rich Edit Controls 以获取更多关于RichEdit版本的信息。