2011. április 20., szerda

Performing Custom Actions on WebBroser.Document's OnClick Event


Problem/Question/Abstract:

The TWebBrowser-object is a great way to display offline html-files within your application. Sometimes it would be nice to react within your delphi-application when the user clicks on a link in the html-view...

Answer:

An onclick event occurs on every HTMLElement on HTMLDocument object whenever a left mouse button pressed and released. This event does not apply for just 'A' tag only. It then bubbled, traversing to its parent. This eases us a bit to accomplish the task by intercepting onclick event on HTMLDocument instead of attaching our handler to each of HTML anchor element (IHTMLAnchorElement). We then can examine on what HTML element the event actually occur.

Using JavaScript to interact with IE DOM, you'll probably write your code like this:

<script language="JavaScript"><!--
function DocumentClicked()
{
  var e = /* window. */event.srcElement;
  // do something with e
  alert(e.tagName);
  return false;
}
//--></script>
<body ... onclick="DocumentClicked()">

Returning false for that function tells IE not to bubble this event and don't perform any of its default action.

When using OLE Automation to control a separately running instance of an application, you will need to create a mechanism to respond to events triggered by that ActiveX object. To create this mechanism, commonly referred to as an event sink.

To achieve the same result with Delphi, it is obvious that we need to capture COM events from Internet Explorer object. Fortunately there is excellent utility made by Binh Ly called EventSinkImp. EventSinkImp is a free utility (comes with full source code for enthusiasts) that imports COM connection point-based event interfaces for ease of use in Delphi applications. EventSinkImp creates stub classes/components that publishes event methods as native Delphi events so that you can easily build applications that need to capture COM-based events from Delphi, Visual Basic, or Visual C++ server COM components.

Actually I quoted the above phrase from EventSinkImp help file:). You can download it from http://www.techvanguards.com/products/eventsinkimp.

Use EventSinkImp utility to generate the Pascal unit file for "Microsoft HTML Object Library" (exposed from %SYSTEM%\mshtml.tlb). With its default options, it will create "MSHTMLEvents.pas" stored in $(DELPHI)\Imports folder. This unit wraps Sink events into TComponent descendant objects and creates RegisterComponents procedure for them so they can appear in ActiveX component palette.

Now let's start Sink something. First we must instantiate a SinkComponent. Drop a TMSHTMLHTMLDocumentEvents component on the Form, or create it at run-time. Here I do the later:

uses...
  , MSHTMLEvents { generated by EventSinkImp utility }
  , SHDocVw { or SHDocVW_TLB                    }
  , mshtml { or MSHTML_TLB                     }
  ;
type
  TForm1 = class(TForm)
    WebBrowser1: TWebBrowser;
    ...
    private
    FSinkComponent: TMSHTMLHTMLDocumentEvents;
    function DocumentOnClick(Sender: TObject): WordBool;
  end;
  {  ... }

procedure TForm1.FormCreate(Sender: TObject);
begin
  FSinkComponent := TMSHTMLHTMLDocumentEvents.Create(Self);
end;

Then we must hook up this event sink to an object which HTMLElements (objects which implements IHTMLElement) reside. It is WebBroser1.Document and it implements IHTMLDocument2 interface. We start to hook up using the following syntax:

FSinkComponent.Connect(WebBrowser1.Document as IHTMLDocument2);
FSinkComponent.onclick := DocumentOnClick;

To unhook, use the following syntax:

FSinkComponent.Disconnect;

Remember that WebBrowser1.Document must contain a valid document prior to calling FSinkComponent.Connect(). WebBrowser1.Document is nil by default and this is not valid parameter for FSinkComponent.Connect() method which expecting an IUnknown object. You can open an URL or just load 'about:blank' page to make the Document valid. My suggestion is call Connect() from WebBrowser1.OnNavigateComplete2 event and optionally call Disconnect() from WebBrowser1.BeforeNavigate2 event.

From this point we can perform a special action whenever an onclick event occurs in WebBrowser1.Document. In the sample above, DocumentOnClick() method will be called. Remember to assign False for its Result unless you want IE to perform the default action for this event.

function TForm1.DocumentOnClick(Sender: TObject): WordBool;
begin
  // do whatever necessary here
  Result := False;
end;

To make something meaningful, let's create a special HTML tag for our HTML document. This tag is special because it contains custom attributes. Later we can retrieve these attributes value to perform an appropriate action. Any HTML tag will do fine as long as IE able to render it visible. I picked the 'A' tag for this purpose because everyone already know that an underlined HTML text is clickable. Here is the sample of our special tags with just one custom attribute:

  <a href="#SomeBogusURL" ActionID="3">Click here!!!</a>
  <br>
  <a href="#SomeBogusURL" ActionID="23">And also here!!!</a>

The custom attribute name is "ActionID". Actually we can also use the "HREF" attribute for this purpose, but I decided not to mess with the standard attributes. We need to adjust the onclick handler. It now looks like this:

function TForm1.DocumentOnClick(Sender: TObject): WordBool;
var
  Element: IHTMLElement;
  ActionID: OleVariant;
begin
  Result := True;
  // find out on what element this event occured
  Element := (TMSHTMLHTMLDocumentEvents(Sender).Source as
    IHTMLDocument2).parentWindow.event.srcElement;
  // We are interesting for elements with 'A' tag, but quite often
  // there are other elements (HTML tags) between <a> and </a>
  // tags which are actually receive the click.
  // Thus we cannot simply check with syntax:
  //    if AnchorElement.tagName = 'A' then ...
  // ... needs a bit more effort to check and we'll traverse if
  // necessary.

  while (Element <> nil) and (Element.tagName <> 'A') do
    Element := Element.Get_parentElement;

  if Element <> nil then
  begin
    // Element is a valid HTMLElement and it is an anchor element.
    // It also implements IHTMLAnchorElement interface in case you need
    // something with that interface.
    // Now we need to examine the value for 'ActionID' attribute
    ActionID := Element.getAttribute('ActionID', 0);
    if TVarData(ActionID).VType = varOleStr then
    begin
      PerformAnActionBasedOnActionID(StrToInt(ActionID));
      Result := False;
    end
    else
      // Attribute ActionID does not exist
      ;
  end;
end;

Hope you got the picture. You can put as many custom attributes as necessary to feed the Delphi code. For example:

  <a href="#" ActionStr="ShowForm" FormName="fmDlg1"
    ShowModal="True">Preference Options</a><br>
  <a href="#" ActionStr="ShowForm" FormName="fmDlg2"
    ShowModal="False">Preview</a><br>
  <a href="#" ActionStr="MessageBox" MsgStr="Hello&#13;World!"
    MsgCaption="A DlgBox" MsgIcon="1">bla bla</a>

And here is the sample custom HTML tags other than 'A' tag.

  <div align="center" ActionID="3" style="cursor:hand">Click me</div>
  <ul>
    <li ActionID="13" style="cursor:hand">Item 1</li>
    <li ActionID="14" style="cursor:hand">Item 2</li>
  </ul>

Go imagine yourself what to do with those attribute values!

I highlight 2 important properties and methods of IHTMLElement object to retrieve the HTML Tag name and attribute value. They are tagName and getAttribute(). The tagName property is already self-explained. I'll give a summarized description of getAttribute() method, quoted from MS Internet Development SDK:

IHTMLElement.getAttribute(
  const strAttributeName: WideString; // specifies the name of the
  // attribute
  lFlags: Integer // specifies one or more of the following flags:
  // 0: Default. Performs a property search that is not
  //    case-sensitive, and returns an interpolated
  //    value if the property is found.
  // 1: Performs a case-sensitive property search.
  //    To find a match, the uppercase and lowercase
  //    letters in strAttributeName must exactly
  //    match those in the attribute name. If the
  //    lFlags parameter for IHTMLStyle::getAttribute
  //    is set to 1 and this option is set to 0
  //    i(default), the specified property name
  //    might not be found.
  // 2: Returns the value exactly as it was set in
  //    script or in the source document.
  ): OleVariant;

Result is OleVariant type. It is a pointer to a VARIANT that returns  a BSTR, number, or VARIANT_BOOL value as defined by the attribute.  If the attribute is not present, this method returns nil.

One more question, how touse this example with frame set?

Basically, all you need to do is hook the SinkComponent to the document within the frame instead of WebBrowser.Document.

Suppose you interest in document within frameset's frame named 'frame1', your code will look like this:

procedure TForm1.WebBrowser1NavigateComplete2(Sender: TObject;
  const pDisp: IDispatch; var URL: OleVariant);
var
  MainDoc, DocumentInFrame: IHTMLDocument2;
  FrameName: OleVariant;
begin
  MainDoc := WebBrowser1.Document as IHTMLDocument2;
  FrameName := 'frame1';
  DocumentInFrame := (IDispatch(MainDoc.Get_frames.item(FrameName)) as
    IHTMLWindow2).document;
  FSinkComponent.Connect(DocumentInFrame);
  {  ... }
end;

This article comes with a downloadable demo source code. This demo shows you how the click on HTML anchors will interact with Delphi's TForm.

It will help a lot if you know some MS Internet Explorer Document Object Model (IE DOM) basic. That topic does not covered here or in demo source code. The complete coverage is available at MSDN online. To learn about COM/OLE Automation/Event Sink stuff in Delphi/BCB environment, please visit Binh Ly website. There are excellent articles, tutorials, sample codes, and load of downloadable goodies.


Component Download: WBOnClickEventSink.zip

Nincsenek megjegyzések:

Megjegyzés küldése