MicrosoftEdge/WebView2Feedback

Ability to handle WM_NCHITTEST and/or receive mouse messages in parent window

Closed this issue ยท 21 comments

Is your feature request related to a problem? Please describe.

I would like to be able to drag the entire application window, or allow dragging of the application window at a specific area, either by returning HTCAPTION from WM_NCHITTEST or by receiving mouse click and move events from the WebView

For the former solution, the topmost WebView rendering window returns HTCLIENT from WM_NCHITTEST which means the message does not propagate to the parent window

For the latter, WebView consumes all mouse messages

I would also like to be able to disable dragging of images and objects within the WebView

Describe the solution you'd like and alternatives you've considered

Could a flag be set on the WebView so that the windows will return HTTRANSPARENT or alternativley, send the WM_NCHITTEST message to the message queue of the parent application window to handle?

Can a flag be set that allows certain mouse messages to be replicated to the parent application window message queue?

A flag to disable the dragging of images etc in the WebView.

Issue #200 actually explains really good how to do it

Thanks for the information, this does look pretty simple if you are using C#.

I'm using C++ so it will be a few more lines of code. I'll give this method a try, hopefully a simpler solution will become available soon.

The feature request was considered in #200, so I've updated labels to be a question.

Let us know how the workaround goes!

Sorry for the necromancing, but #200 is all about a specific use case for needing to forward WM_NCHITTEST. There are other scenarios where having the message sent to the parent is a requirement with no work arounds (snapassist!). Is this forwarding of events still not being considered? Thanks.

@leaanthony Are you generically concerned about handling WM_NCHITTEST, or is there a specific scenario(s), like snap assist, that you are interested in?

Thanks for replying @champnic.

Snap Assist is front of mind, but it doesn't make sense to keep hacking solutions for all current and future scenarios when a simple hook for the hit test could potentially suffice. I do recognise that this might be part of a wider issue regarding forwarding events to the host.

We're currently looking into solutions for the captions controls (min/max/close, including snap assist) likely building on the Windows Control Overlay (WCO) work that exists for PWA: https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/window-controls-overlay

Going with the WCO approach would be less customizable than a hit-test hook or doing something like an html attribute for "app-region: close" to specify what different web regions should hit-test as, but would something like WCO work for the cases you care about?

It might do. My concern is unless all the vendors support these features, then projects that support multiple platforms will still need custom code to hack around the disparity. This is already true for app-region: drag. I know that this isn't the edge team's concern but it is a concern for projects that need to support cross-platform webview applications (not PWAs). I guess what I'm saying is that if it's programmatic outside of the DOM, then projects that extensively use webviews can implement their own solution until such a standard exists and is available within the DOM.

Ah understood! That's a good point and not one we usually consider. I can reopen this to track that request, though I'm not sure it will end up changing our design. Most likely scenario is that you would need to take however you choose to expose that functionality and convert it to use our solution, like adding "app-region: drag" on behalf of the dev.

Do you have an idea of how you would choose to respond to the hit-test? Would you coordinate locations of html elements to the native side and then if the point is within one of those elements return something like HTMAXBUTTON, for example?

The other option is to use the CompositionController which uses a visual to hook up to the WebView2, and then the app would be the one receiving hit-test directly and can decide when to forward to the WebView2 or handle themselves.

Ah understood! That's a good point and not one we usually consider. I can reopen this to track that request, though I'm not sure it will end up changing our design. Most likely scenario is that you would need to take however you choose to expose that functionality and convert it to use our solution, like adding "app-region: drag" on behalf of the dev.

I'm not sure the devs would appreciate us parsing their HTML and injecting attributes ๐Ÿ˜… Also, app-region:drag is inconsistent across the platforms so we still need to implement our own solution (not just my project but other popular projects too).

Do you have an idea of how you would choose to respond to the hit-test? Would you coordinate locations of html elements to the native side and then if the point is within one of those elements return something like HTMAXBUTTON, for example?

That's exactly right. It's the same mechanism for drag: when there's a mousedown in JS, the event handler checks for certain CSS properties and sends a message to the host based on what's there. In this case, we'd send a message back if the mouse enters a max button and potentially trigger a small timer. The exact sequence of events on how Snap Assist is triggered by WM_NCHITTEST and the HTMAXBUTTON response is still slightly unclear to me but I'm sure we could make it work.

The other option is to use the CompositionController which uses a visual to hook up to the WebView2, and then the app would be the one receiving hit-test directly and can decide when to forward to the WebView2 or handle themselves.

Yeah, we've been talking about this internally and are still yet to determine to side effects of this approach.

Again, thanks for your time @champnic ๐Ÿ™

Thanks for the info! Another option to consider that wouldn't be blocked by our work is to create and manage transparent HWNDs above the WebView2 where you need to do the manual hit-testing. We have examples of apps doing this today to implement the caption controls. The downside is mostly the complexity of maintaining consistency between the HTML elements and the corresponding HWND (size and position). And you also would need to disable the HWND if the HTML is obscured by other elements, have inner HWNDs or regions that ignore hit-test if the HTML element has an inner element that disables something (like "no-drag" inside of a "drag" element), etc. But it seems to work ok for simpler cases.

This is certainly possible but sounds like it might be very difficult to manage with any window that has scrolling content. Thanks for the info though!

@champnic can we reconsider adding this? our use-case is probably the same as @leaanthony, at Tauri, we have been working around this issue for so long, but it is not optimal at all.

Having a hook to WM_NCHITTEST would allow:

  • Handling HTMAXBUTTON
  • Resizing borderless window by checking if a click happened in a margin around the webview then we can prevent the WebView default and pass the click to the parent to start resizing.
  • Custom drag regions, even though app-region: drag exists, we can't use it because we need to support other platforms that don't have support for app-region: drag and so we endedup implementing a custom data-tauri-drag-region attribute and although it does work for mouse clicks, we are unable to make it work with touch.

I honestly don't know why the webview2 team doesn't want to expose this hook, I may be missing something, so I'd appreciate some explanation. Thanks for your work.

It's not quite as simple as just exposing a hook to WM_NCHITTEST. For WebView2 when creating CoreWebView2Controller, the WM_NCHITTEST message arrives in the msedgewebview2.exe process. Introducing a hook would require blocking the WebView2 process to call into the app process, wait for the app to handle the event and then report the answer. That cross-proc round trip on every WM_NCHITTEST could cause noticeable delays for the user. Additionally, returning HTMAXBUTTON or HTCAPTION from the msedgewebview2.exe process will result in the WebView2 child HWND being maximized or moved, not the application's top level window.

@champnic's suggestion to use the CoreWebView2CompositionController is the best option to get the granular control over hittesting and mouse input that you seek. Using the CompositionController enables the WebView2 to render to a Visual instead of an HWND (and additionally you could use the GraphicsCapture APIs to render the Visual content into a SwapChain or D3DTexture). Without the child HWND, the WM_NCHITTEST message goes to the application's HWND, allowing you to return the appropriate HT* response, and then the behavior applies to your application window.

Thanks @bradp0721

It's not quite as simple as just exposing a hook to WM_NCHITTEST. For WebView2 when creating CoreWebView2Controller, the WM_NCHITTEST message arrives in the msedgewebview2.exe process. Introducing a hook would require blocking the WebView2 process to call into the app process, wait for the app to handle the event and then report the answer. That cross-proc round trip on every WM_NCHITTEST could cause noticeable delays for the user.

Is there an estimate of how much delay we are talking here? maybe it won't be that noticeable for most applications and our current solution is not the most optimal either and I think the hook would be faster anyway.

Additionally, returning HTMAXBUTTON or HTCAPTION from the msedgewebview2.exe process will result in the WebView2 child HWND being maximized or moved, not the application's top level window.

I see, didn't think of that.

use the CoreWebView2CompositionController is the best option to get the granular control over hittesting and mouse input that you seek.

We still didn't drop Windows 7 support so we can't switch over to CoreWebView2CompositionController yet :(

@amrbashir Unfortunately we no longer support updates for Win7 and Win8/8.1. So any new work we did wouldn't affect those platforms anyways :(

For borders, our typical workaround is to make the WebView2 not go completely to the bounds of the window. If you wanted to make it borderless though, then probably adding a hit-testable but invisible HWND as the broders could work. For the caption controls, we are looking at adding native captions controls drawn/controlled by the WebView2 for when the app is controlling the titlebar/non-client region. However, it won't support much in the way of customization, besides height (so will look like default Windows min/max/close buttons). Will that work for you, or do you need full customization of the caption controls?

@amrbashir Unfortunately we no longer support updates for Win7 and Win8/8.1. So any new work we did wouldn't affect those platforms anyways :(

Oh, I guess we are stuck with our current solution then until we drop Win7

For borders, our typical workaround is to make the WebView2 not go completely to the bounds of the window. If you wanted to make it borderless though, then probably adding a hit-testable but invisible HWND as the broders could work.

Do you mean to add 4 invisible HWNDs at each border?

For the caption controls, we are looking at adding native captions controls drawn/controlled by the WebView2 for when the app is controlling the titlebar/non-client region. However, it won't support much in the way of customization, besides height (so will look like default Windows min/max/close buttons). Will that work for you, or do you need full customization of the caption controls?

that would be enough probably

Posting here in case anyone else needs this, this is how I created a HWND placed on top of the webview that contains a cut-out region to allow clicks to pass to the webview but provides resizing on the edges.

You may need to change the code a bit if you want to remove resize handles when the parent window is maximized, or fullscreen.

example in rust

use windows::core::*;
use windows::Win32::System::LibraryLoader::*;
use windows::Win32::UI::WindowsAndMessaging::*;
use windows::Win32::{Foundation::*, UI::Shell::*, Graphics::Gdi::*};

unsafe fn create_resizable_borders(parent: HWND) {
  let class_name = w!("DRAG_WINDOW");

  let class = WNDCLASSEXW {
    cbSize: std::mem::size_of::<WNDCLASSEXW>() as u32,
    style: WNDCLASS_STYLES::default(),
    lpfnWndProc: Some(default_window_proc),
    cbClsExtra: 0,
    cbWndExtra: 0,
    hInstance: unsafe { HINSTANCE(GetModuleHandleW(PCWSTR::null()).unwrap_or_default().0) },
    hIcon: HICON::default(),
    hCursor: HCURSOR::default(),
    hbrBackground: HBRUSH::default(),
    lpszMenuName: PCWSTR::null(),
    lpszClassName: class_name,
    hIconSm: HICON::default(),
  };

  RegisterClassExW(&class);

  let mut rect = RECT::default();
  GetClientRect(parent, &mut rect).unwrap();
  let width = rect.right - rect.left;
  let height = rect.bottom - rect.top;

  let padded_border = GetSystemMetrics(SM_CXPADDEDBORDER);
  let border_x = GetSystemMetrics(SM_CXFRAME) + padded_border;
  let border_y = GetSystemMetrics(SM_CYFRAME) + padded_border;

  let hwnd = CreateWindowExW(
    WINDOW_EX_STYLE::default(),
    class_name,
    PCWSTR::null(),
    WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS,
    0,
    0,
    width,
    height,
    parent,
    HMENU::default(),
    GetModuleHandleW(PCWSTR::null()).unwrap_or_default(),
    None,
  );

  let hrgn1 = CreateRectRgn(0, 0, width, height);
  let hrgn2 = CreateRectRgn(border_x, border_y, width - border_x, height - border_y);
  CombineRgn(hrgn1, hrgn1, hrgn2, RGN_DIFF);
  SetWindowRgn(hwnd, hrgn1, true);

  SetWindowPos(
    hwnd,
    HWND_TOP,
    0,
    0,
    0,
    0,
    SWP_ASYNCWINDOWPOS | SWP_NOACTIVATE | SWP_NOMOVE | SWP_NOOWNERZORDER | SWP_NOSIZE,
  )
  .unwrap();

  SetWindowSubclass(
    parent,
    Some(subclass_parent),
    (WM_USER + 1) as _,
    hwnd.0 as _,
  )
  .unwrap();
}

unsafe extern "system" fn subclass_parent(
  hwnd: HWND,
  msg: u32,
  wparam: WPARAM,
  lparam: LPARAM,
  _: usize,
  child: usize,
) -> LRESULT {
  match msg {
    WM_SIZE => {
      let child = HWND(child as _);

      let mut rect = RECT::default();
      GetClientRect(hwnd, &mut rect).unwrap();
      let width = rect.right - rect.left;
      let height = rect.bottom - rect.top;

      let padded_border = GetSystemMetrics(SM_CXPADDEDBORDER);
      let border_x = GetSystemMetrics(SM_CXFRAME) + padded_border;
      let border_y = GetSystemMetrics(SM_CYFRAME) + padded_border;

      let hrgn1 = CreateRectRgn(0, 0, width, height);
      let hrgn2 = CreateRectRgn(border_x, border_y, width - border_x, height - border_y);
      CombineRgn(hrgn1, hrgn1, hrgn2, RGN_DIFF);
      SetWindowRgn(child, hrgn1, true);

      SetWindowPos(
        child,
        HWND_TOP,
        0,
        0,
        width,
        height,
        SWP_ASYNCWINDOWPOS | SWP_NOACTIVATE | SWP_NOOWNERZORDER | SWP_NOMOVE,
      )
      .unwrap();
    }

    _ => {}
  }

  DefSubclassProc(hwnd, msg, wparam, lparam)
}

unsafe extern "system" fn default_window_proc(
  hwnd: HWND,
  msg: u32,
  wparam: WPARAM,
  lparam: LPARAM,
) -> LRESULT {
  match msg {
    WM_NCHITTEST => {
      let (res, _, _) = hit_test(hwnd, lparam);
      return LRESULT(res);
    }

    WM_NCLBUTTONDOWN => {
      let (res, cx, cy) = hit_test(hwnd, lparam);
      if res != HTTRANSPARENT as isize {
        let points = POINTS {
          x: cx as i16,
          y: cy as i16,
        };

        PostMessageW(
          GetParent(hwnd),
          WM_NCLBUTTONDOWN,
          WPARAM(res as _),
          LPARAM(&points as *const _ as _),
        )
        .unwrap();

        return LRESULT(0);
      }
    }

    _ => {}
  }

  DefWindowProcW(hwnd, msg, wparam, lparam)
}

unsafe fn hit_test(hwnd: HWND, lparam: LPARAM) -> (isize, i32, i32) {
  let mut rect = RECT::default();
  GetWindowRect(hwnd, &mut rect).unwrap();

  let padded_border = GetSystemMetrics(SM_CXPADDEDBORDER);
  let border_x = GetSystemMetrics(SM_CXFRAME) + padded_border;
  let border_y = GetSystemMetrics(SM_CYFRAME) + padded_border;
  let (cx, cy) = (GET_X_LPARAM(lparam) as i32, GET_Y_LPARAM(lparam) as i32);

  const LEFT: isize = 0b0001;
  const RIGHT: isize = 0b0010;
  const TOP: isize = 0b0100;
  const BOTTOM: isize = 0b1000;
  const TOPLEFT: isize = TOP | LEFT;
  const TOPRIGHT: isize = TOP | RIGHT;
  const BOTTOMLEFT: isize = BOTTOM | LEFT;
  const BOTTOMRIGHT: isize = BOTTOM | RIGHT;

  let result = (LEFT * (cx < rect.left + border_x) as isize)
    | (RIGHT * (cx >= rect.right - border_x) as isize)
    | (TOP * (cy < rect.top + border_y) as isize)
    | (BOTTOM * (cy >= rect.bottom - border_y) as isize);

  let res = match result {
    LEFT => HTLEFT as _,
    RIGHT => HTRIGHT as _,
    TOP => HTTOP as _,
    BOTTOM => HTBOTTOM as _,
    TOPLEFT => HTTOPLEFT as _,
    TOPRIGHT => HTTOPRIGHT as _,
    BOTTOMLEFT => HTBOTTOMLEFT as _,
    BOTTOMRIGHT => HTBOTTOMRIGHT as _,
    _ => HTTRANSPARENT as _,
  };

  (res, cx, cy)
}

/// Implementation of the `GET_X_LPARAM` macro.
#[allow(non_snake_case)]
#[inline]
pub fn GET_X_LPARAM(lparam: LPARAM) -> i16 {
  ((lparam.0 as usize) & 0xFFFF) as u16 as i16
}

/// Implementation of the `GET_Y_LPARAM` macro.
#[allow(non_snake_case)]
#[inline]
pub fn GET_Y_LPARAM(lparam: LPARAM) -> i16 {
  (((lparam.0 as usize) & 0xFFFF_0000) >> 16) as u16 as i16
}

Edit: You could call GetWindowLongPtrW(hwnd, GWL_STYLE) and check if it has WS_SIZEBOX style and decide whether to proceed with resizing logic or return with the value of DefWindowProcW, this should disable resizing when the window is maximized or in fullscreen or if it is not resizable in the first place.

Thanks @amrbashir ๐Ÿ™ Appreciate it.