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

改进的 CEdit 控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (30投票s)

2011年10月10日

Zlib

7分钟阅读

viewsIcon

62833

downloadIcon

4274

CEdit 派生控件,提供额外的编辑选项和多级撤销/重做功能。

引言

CEdit 控件仅提供基本的编辑功能,如剪切/复制/粘贴和单级撤销。值得注意的是,单行 CEdit 控件的撤销会恢复所有连续的文本更改,而在多行控件中,则只撤销最后一个字符的更改。这种不一致性对于最终用户来说可能非常恼人,特别是当对话框同时包含单行和多行编辑控件时。

我决定改进编辑控件的撤销/重做功能,并允许多级撤销/重做。此外,我还选择包含一些编辑选项,例如从当前光标位置删除文本直到下一个/上一个字符块,或删除到文本的末尾/开头。最后,该控件需要支持标准的键盘快捷键,如 Ctrl + A、Ctrl + Z 和 Ctrl + Y。语法高亮和自动完成等高级功能已被省略,因为它们是在派生类中处理的。

背景

为了实现上述要求,定义了派生自 CEdit 控件的 CEditEx 类。CEditEx 会拦截编辑操作,以便创建相应的撤销/重做操作。创建并推送到撤销堆栈后,编辑命令会被传递给基类进行进一步处理。这样可以确保派生类(例如负责自动完成的类)接收到的消息与直接派生自 CEdit 类的类接收到的消息相同。

实现

下面是对实现中最重要部分的简要说明。

多级撤销和重做

多级撤销/重做是使用命令设计模式实现的。所有命令都派生自抽象类 CEditExCommand,该类具有纯虚函数 DoUndoDoExecute。这些方法在派生类中实现。命令存储在 CCommandHistory 类中,该类包含撤销和重做堆栈。

extendedcedit/CommandsClassDiagram.png

请注意,上述所有类都被定义为 CEditEx 类中的局部类,使得整个代码紧凑且易于重用。

如前所述,必须拦截编辑操作以创建撤销/重做命令。识别必须处理的消息非常重要

  • WM_CHAR 用于跟踪输入的字符;
  • WM_PASTE 用于捕获将要从剪贴板粘贴的文本;
  • WM_CUTWM_CLEAR(由上下文菜单中的 Delete 命令生成),用于捕获将要删除的文本;
  • WM_UNDO 用于从 CCommandHistory 调用撤销操作。此消息不得传递给基类 CEdit,因为它会以自己的方式处理。

上述消息在重写的 WindowProc() 方法中处理

LRESULT CEditEx::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_CHAR:
        {
            wchar_t wChar = static_cast<wchar_t>(wParam);
            int nCount = lParam & 0xFF;
            if (iswprint(wChar)) {
                CString newText(wChar, nCount);
                CreateInsertTextCommand(newText);
            }
            // ...
        }
        break;
    case WM_PASTE:
        CreatePasteCommand();
        break;
    case WM_CUT:
    case WM_CLEAR: // delete command from the context menu
        if (!IsSelectionEmpty())
            m_commandHistory.AddCommand(new CDeleteSelectionCommand(this, 
                                        CDeleteSelectionCommand::Selection));
        break; 
    case WM_UNDO:
        Undo();
        return TRUE; // we did the undo and shouldn't pass the message to base class
    }
    return CEdit::WindowProc(message, wParam, lParam);
}

CreateInsertTextCommand()CreatePasteCommand() 是创建相应编辑操作命令的私有方法。Undo() 是重写的实现,它从撤销堆栈中弹出一个命令并执行它。

应该注意的是,Alt + Back 键组合会生成 WM_UNDO 消息,该消息在重写的 WindowProc() 方法中处理。但是,Alt + Shift + Back 会生成相同的消息,尽管此组合用于重做命令。因此,在调用重写的 Undo() 方法之前,必须进行额外的检查。

// ...
// Alt + Shift + Back also generates WM_UNDO so 
// we must distinguish it to execute redo operation
if ((GetKeyState(VK_BACK) & 0x8000) & (GetKeyState(VK_SHIFT) & 0x8000))
    Redo();
else
    Undo();

Unicode 控制字符

在较新版本的 Windows 编辑控件中,上下文菜单包含额外的项目,例如输入特定 Unicode 控制字符的选项。其中大多数 Unicode 控制字符在组合从左到右和从右到左书写时很重要。

extendedcedit/UnicodeControlCharacters.png

当用户从菜单中选择这些控制字符之一时,会向 CEdit 控件发送 WM_CHAR 消息。但与键盘输入生成的 WM_CHAR 消息不同,此消息的重复计数(包含在 lParam 参数中)始终为 0。之后,此消息将在 WindowProc() 方法中单独处理。

LRESULT CEditEx::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_CHAR:
        {
            wchar_t wChar = static_cast<wchar_t>(wParam);
            int nCount = lParam & 0xFF;
            if (iswprint(wChar))
            {
                CString newText(wChar, nCount);
                CreateInsertTextCommand(newText);
            }
            // special case for Unicode control characters inserted from the context menu
            else if (nCount == 0)
            {
                CString newText(wChar);
                CreateInsertTextCommand(newText);
            }
        }
        break;
    // ...
    }
    return CEdit::WindowProc(message, wParam, lParam);
}

额外的块删除操作

额外的块删除操作包括

  • 从当前光标位置到块末尾删除文本(键盘快捷键 Ctrl + Del);
  • 从当前光标位置到文本末尾删除文本(Ctrl + Shift + Del);
  • 从文本块开头到当前光标位置删除文本(Ctrl + Back);
  • 从文本开头到当前光标位置删除文本(Ctrl + Shift + Back)。

文本块分隔符是通过空格字符、标点符号或字母数字字符之间的类型更改来识别的,就像在 Visual Studio 源代码编辑器中一样。键盘快捷键在重写的 PreTranslateMessage() 方法中捕获,其中处理 WM_KEYDOWN 消息。

BOOL CEditEx::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    case WM_KEYDOWN:
        if (PreTranslateKeyDownMessage(pMsg->wParam) == TRUE)
            return TRUE;
        break;
    // ...
    }
    return CEdit::PreTranslateMessage(pMsg);
}

BOOL CEditEx::PreTranslateKeyDownMessage(WPARAM wParam)
{
    switch (wParam)
    {
    // ...
    case VK_DELETE:
        return DoDelete();
    case VK_BACK:
        return DoBackspace();
    }
    return FALSE;
}

BOOL CEditEx::DoDelete()
{
    if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
    {
        // Ctrl + Delete + Shift
        if ((GetKeyState(VK_SHIFT) & 0x8000) != 0)
            DeleteToTheEnd();
        // Ctrl + Delete
        else
            DeleteToTheBeginningOfNextWord();
        return TRUE;
    }
    // simple delete
    if (IsSelectionEmpty() == false)
        m_commandHistory.AddCommand(new CDeleteSelectionCommand(this, 
                                    CDeleteSelectionCommand::Selection));
    else
    {
        if (GetCursorPosition() < GetWindowTextLength())
            m_commandHistory.AddCommand(new CDeleteCharacterCommand(this, false));
    }
    return FALSE;
}

BOOL CEditEx::DoBackspace()
{
    if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
    {
        if ((GetKeyState(VK_SHIFT) & 0x8000) != 0)
            // Ctrl + Shift + Back
            DeleteFromTheBeginning();
        else
            // Ctrl + Back
            DeleteFromTheBeginningOfWord();
        return TRUE;
    }
    // plain Back
    if (IsSelectionEmpty() == false)
        m_commandHistory.AddCommand(new CDeleteSelectionCommand(this, 
                                    CDeleteSelectionCommand::Selection));
    else
    {
        if (GetCursorPosition() > 0)
            m_commandHistory.AddCommand(new CDeleteCharacterCommand(this, true));
    }
    return FALSE;
}

DeleteToTheEnd()DeleteToTheBeginningOfNextWord()DeleteFromTheBeginning()DeleteFromTheBeginningOfWord() 是简单地扩展当前选择并应用相应命令删除所选内容的函数。

键盘快捷键

这是最简单的任务:PreTranslateMessage() 方法必须处理以下快捷键的 WM_KEYDOWN 消息

  • Ctrl + A 选择全部文本;
  • Ctrl + Z 撤销操作;
  • Ctrl + Y 重做操作

并调用相应的操作

BOOL CEditEx::PreTranslateKeyDownMessage(WPARAM wParam)
{
    switch (wParam)
    {
    case _T('A'):
        // Ctrl + 'A'
        if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
        {
            SetSel(0, -1);
            return TRUE;
        }
        break;
    case _T('Z'):
        // Ctrl + 'Z'
        if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
        {
            Undo();
            return TRUE;
        }
        break;
    case _T('Y'):
        // Ctrl + 'Y'
        if ((GetKeyState(VK_CONTROL) & 0x8000) != 0)
        {
            Redo();
            return TRUE;
        }
        break;
    // ...
}

Redo()Undo() 方法一样,也是重写的实现,它从重做堆栈中获取一个命令并执行它。

对于单行编辑控件,Alt + Back 默认会生成 WM_UNDO 消息,因此不需要额外的代码。然而,在多行模式下,CEdit 会简单地忽略此键盘快捷键!因此,有必要在 PreTranslateMessage() 中通过生成的 WM_SYSCHAR 消息捕获 Alt + Back 快捷键,并调用 Undo() 方法。

BOOL CEditEx::PreTranslateMessage(MSG* pMsg)
{
    switch (pMsg->message)
    {
    // ...
    case WM_SYSCHAR:
        // Alt + Back
        if (pMsg->wParam == VK_BACK)
        {
            // for single-line Alt + Back generates
            // WM_UNDO message but not for multiline!
            // Therefore we need to capture
            // this keyboard shortcut for multiline control
            if (IsMultiLine())
            {
                if (GetKeyState(VK_SHIFT) & 0x8000)
                    Redo();
                else
                    Undo();
            }
            return TRUE;
        }
        break;
    }
    return CEdit::PreTranslateMessage(pMsg);
}

处理 SetWindowText() 函数引起的变化

控件的初始实现没有跟踪由调用 SetWindowText() 方法引起的变化。这可能很烦人,尤其是在控件内容由 DDX_Text() 函数修改时(该函数调用 SetWindowText() 函数),正如其中一个评论中所指出的那样。为了避免这种情况,WM_SETTEXT 消息处理已包含在 CEditEx::WindowProc() 中。但是,需要注意的是,有两种情况不应跟踪 WM_SETTEXT 消息

  1. 当控件用父窗口的 OnInitDialog() 方法中的初始文本填充时;
  2. SetWindowText() 从命令历史记录中的方法调用以执行撤销/重做命令时。

忽略第一种情况将允许用户执行超出初始状态的撤销操作,最终导致控件内容为空。这可以通过检查控件是否可见来轻松解决;如果控件尚未可见,则不会将 CSetTextCommand 命令添加到历史记录中。

第二种情况会导致历史记录中的命令混乱。我们需要以某种方式区分文本是由外部调用设置的还是从命令历史记录内部设置的。为了达到这个目的,采用了一个简单的技巧。WM_SETTEXT 消息仅使用 lParam 参数(实际上是指向要设置的字符串的指针),而 wParam 等于 NULL。为了区分不应推送到命令历史记录的消息,所有来自命令历史记录的内部调用都通过将 wParam 设置为编辑控件句柄的值来完成。例如

void CEditEx::InsertText(const CString& textToInsert, int nStart)
{
    ASSERT(nStart <= GetWindowTextLength());
    CString text;
    GetWindowText(text);
    text.Insert(nStart, textToInsert);
    SendMessage(WM_SETTEXT, (WPARAM)m_hWnd, (LPARAM)text.GetString());
}

因此,消息处理代码如下

LRESULT CEditEx::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    // ...
    case WM_SETTEXT:
        // add event to history only if control is already visible (to prevent
        // initial call of DDX_Text to be added to command history) and
        // if message has been sent from outside the control (i.e. wParam is NULL)
        if (IsWindowVisible() && (HWND)wParam != m_hWnd)
            m_commandHistory.AddCommand(new CSetTextCommand(this));
        break;
    }
    return CEdit::WindowProc(message, wParam, lParam);
}

更新上下文菜单

原始上下文菜单包含撤销(WM_UNDO)、剪切(WM_CUT)、复制(WM_COPY)、粘贴(WM_PASTE)、删除(WM_CLEAR)和全选(EM_SETSEL)条目。如果使用默认上下文菜单,那么唯一的问题是如何正确启用和禁用“撤销”菜单项。一种简单的方法是实现 WM_ENTERIDLE 消息处理程序,并检查它是否是因为菜单已显示而被调用。然后获取菜单的句柄,并通过检查撤销命令历史记录是否为空来启用或禁用 WM_UNDO 命令。

BEGIN_MESSAGE_MAP(CEditEx, CEdit)
    ON_WM_CONTEXTMENU()
    ON_WM_ENTERIDLE()
END_MESSAGE_MAP()

void CEditEx::OnContextMenu(CWnd* pWnd, CPoint point)
{
    m_contextMenuShownFirstTime = true;
    CEdit::OnContextMenu(pWnd, point);
}

void CEditEx::OnEnterIdle(UINT nWhy, CWnd* pWho)
{
    CEdit::OnEnterIdle(nWhy, pWho);
    if (nWhy == MSGF_MENU)
    {
        // update context menu only first time it is displayed
        if (m_contextMenuShownFirstTime)
        {
            m_contextMenuShownFirstTime = false;
            UpdateContextMenuItems(pWho);
        }
    }
}

void CEditEx::UpdateContextMenuItems(CWnd* pWnd)
{
    MENUBARINFO mbi = {0};
    mbi.cbSize = sizeof(MENUBARINFO);
    ::GetMenuBarInfo(pWnd->m_hWnd, OBJID_CLIENT, 0, &mbi);
    HMENU hMenu = mbi.hMenu;
    if (m_commandHistory.CanUndo()) 
        ::EnableMenuItem(hMenu, WM_UNDO, MF_BYCOMMAND | MF_ENABLED);
    else
        ::EnableMenuItem(hMenu, WM_UNDO, MF_BYCOMMAND | MF_GRAYED);
}

在讨论如何修改默认上下文菜单之前,应该指出内置的上下文菜单是本地化感知的,即使应用程序的其余部分没有本地化,它也会显示本地化语言的条目,如下面的截图所示。

extendedcedit/ChineseWindows.png

有两种方法可以修改上下文菜单

  1. 用一个全新的自定义菜单替换它;或
  2. 动态修改默认菜单。

第二种方法需要的工作量较少:只需在 WM_ENTERIDLE 消息处理程序中修改菜单即可。例如,要在“撤销”下方插入“重做”菜单项,UpdateContextMenuItems() 的上述代码应包含对 InsertMenu() 函数的调用。

void CEditEx::UpdateContextMenuItems(CWnd* pWnd)
{
    // ...
    int pos = FindMenuPos(pMenu, WM_UNDO);
    if (pos == -1)
        return;

    static TCHAR* strRedo = _T("&Redo");
    MENUITEMINFO mii;
    mii.cbSize = sizeof(MENUITEMINFO);
    mii.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING;
    mii.fType = MFT_STRING;
    mii.fState = m_commandHistory.CanRedo() ? MF_ENABLED : MF_DISABLED;
    mii.wID = ID_EDIT_REDO;
    mii.dwTypeData = strRedo;
    mii.cch = _tclen(strRedo);
    VERIFY(pMenu->InsertMenuItem(pos + 1, &mii, TRUE));
}

FindMenuPos() 是一个辅助方法

UINT CEditEx::FindMenuPos(CMenu* pMenu, UINT myID)
{
    for (UINT pos = pMenu->GetMenuItemCount() - 1; pos >= 0; --pos)
    {
        MENUITEMINFO mii;
        mii.cbSize = sizeof(MENUITEMINFO);
        mii.fMask = MIIM_ID;
        if (pMenu->GetMenuItemInfo(pos, &mii, TRUE) == FALSE)
            return -1;

        if (mii.wID == myID)
            return pos;
    }
    return -1;
}

此外,我们还可以将键盘快捷键显示在上下文菜单中

void CEditEx::UpdateContextMenuItems(CWnd* pWnd)
{
    // ...
    AppendKeyboardShortcuts(pMenu, WM_UNDO, _T("Ctrl+Z"));
    AppendKeyboardShortcuts(pMenu, ID_EDIT_REDO, _T("Ctrl+Y"));
    AppendKeyboardShortcuts(pMenu, WM_CUT, _T("Ctrl+X"));
    AppendKeyboardShortcuts(pMenu, WM_COPY, _T("Ctrl+C"));
    AppendKeyboardShortcuts(pMenu, WM_PASTE, _T("Ctrl+V"));
    AppendKeyboardShortcuts(pMenu, WM_CLEAR, _T("Del"));
    AppendKeyboardShortcuts(pMenu, EM_SETSEL, _T("Ctrl+A"));
}

void CEditEx::AppendKeyboardShortcuts(CMenu* pMenu, UINT id, LPCTSTR shortcut)
{
    CString caption;
    if (pMenu->GetMenuString(id, caption, MF_BYCOMMAND) == 0)
        return;

    caption.AppendFormat(_T("\t%s"), shortcut);
    MENUITEMINFO mii;
    mii.cbSize = sizeof(MENUITEMINFO);
    mii.fMask = MIIM_STRING;
    mii.fType = MFT_STRING;
    mii.dwTypeData = caption.GetBuffer();
    mii.cch = caption.GetLength();
    pMenu->SetMenuItemInfo(id, &mii);
}

下面的截图显示了生成的菜单。

但是,除了菜单在已显示时重排导致的额外闪烁外,如果插入的菜单项没有提供翻译,这种方法可能会导致不一致,因为原始菜单项会以本地化语言显示。

用全新的菜单替换内置上下文菜单需要更多的工作,特别是如果我们想模仿带有 Unicode 支持条目和其他特定内容的默认上下文菜单。另一方面,通过受控的本地化,此菜单将与我们应用程序的其余部分保持一致。

如何使用代码

只需将 EditEx.hEditEx.cpp 文件包含到您的项目中,并将所有 CEdit 类实例替换为 CEditEx 类即可。

历史

  • 2011 年 10 月 10 日 - 初始版本
  • 2014 年 12 月 7 日 - 添加了对外部调用 SetWindowText() 的处理,添加了对 Alt + Shift + Back 键组合的处理

致谢

纪念我父亲 Bojan Šribar (1931 - 2011)。

© . All rights reserved.