Thursday, June 19, 2014

Communicating With CPWebView

Special thanks to Alexander Ljungberg for pointing me in the correct direction for this exploratio into event handling and Cappuccino <-> iFrame communication.

We learned earlier that we can add custom HTML to a webpage, if we choose, by using the CPWebView class.  This class adds an iframe to our document.  IFrames are particularly difficult to work with in javascript if you have not done so before, so we must review how to do some basic things with our iframe tags and make them more functional.  This tutorial regards javascript more than cappuccino, but it will be useful in your overall Cappuccino experience.

In this tutorial, we will primarily learn:

  • How to access the DOM of the iframe
  • How to pass messages between the iframe and Cappuccino

To learn these things, we will be building a custom text editor to introduce a editable wrapping text field into our program, that does not rely on the <input /> form field in order to edit the string data. This tutorial is for the purpose of learning about Cappuccino and how it relates to javascript. If you are just interested in a text editor, I highly recommend WKTextView, which we review in this blog in an earlier post covering WKTextView and CPWebView. (The input field of CPTextField is limited to a single line in all browsers, so the text does not wrap while being edited, and only displays as being wrapped after all the editing is done.)

Our WebView

Let us create a web view to display a text area.  Later we will abandon the text area, because the text area does not allow us to display text of differing formats.  However, as far as wrapping and editable text is concerned, the text area will server our purposes well (at first).

In AppControler.j, add the following to the applicationDidFinishLaunching method:
/*****************
 textview will be our Cappuccino object. It is an instance of CPWebView. When we create it, we define the origin and size by using CGRectMake. This web view is located at (10,10) and has dimensions of 500 x 500.
********************/
var textview = [[CPWebView alloc] initWithFrame:CGRectMake( 10, 10, 500, 500 )];
/********************
 our html will be hard coded for now.  We create a textarea element, and set the style settings so that it fills the entire available display area.  So that we know where the textarea is, we will color our text area "lightgreen"; this will make it easy to see on our white page background, which exists outside the web view. We use the style property of the textarea so that we do not have to worry about accidentally affecting other elements within the page.  Because iFrames are independent web page views, the style applied here cannot effect other areas of the document; it is entirely self contained. However, this is a safe practice anyway, especially when we are generating our code programmatically. On the other hand, setting style on an element by element basis restricts our ability to make page-wide changes later by changing the css.
*********************/
var textareaHTML = "<html><body><textarea style=\"background-color:lightgreen; position:absolute; left:0; top:0; width:100%; height:100%; border:none;\" id=\"textarea_7129\" placeholder=\"Enter some text here.\"></textarea></html>";
/*******************
now we can apply the HTML string to the web view
********************/
[textview loadHTMLString:textareaHTML];
/******************
and add the web view to the Cappuccino contentView variable defined earlier within the applicationDidFinishLaunching method.
******************/
[contentView addSubview:textview];

Load your application, and you will see the following. Note, mine still shows the "Hello World!" text, because I did not remove the label.  If you have removed the label from your default application, your page will appear slightly different:
Our <textarea> displayed with a CPWebView as seen in Google Chrome.

Javascript, the Console, and getting to our new DOMElement

A Note On The Environment Used in These Screenshots

If you are using Google Chrome, you are able to open the console (part of the Developer Tools, which is included in all Google Chrome installations).  We can play with the page from here and make changes using javascript.  Sometimes this is a good place to experiment with code ideas or just to get familiar with the structure of the DOM (Document Object Model).  If you are not using Google Chrome, you may have an alternative method of doing these same tests; worst case scenario, edit the HTML on your index.html page and add a <script> element that you can modify directly.

Understanding the Console while Playing with our Objects through Javascript

First, let's play a bit to see how this works:

If we type a variable into the console and hit enter, the console will display the contents of the variable and then offer us a new prompt for further instructions.  If the variable content is an object, then the console gives us a series of drill-down options to open each object and view the attributes of the object.  In this screenshot, we see that the #document object contains two nodes: "<!DOCTYPE html>" and another DOM Element containing the contents of the "<html...>...</html>" tags.
We can call functions as well.  For example, in javascript, if we don't know the name or id of an object, we can use the tag name to get an array of all the elements having that tag name in the DOM.  In the next screenshot, we look for all iframes and return an array, which is displayed in JSON notation as: [ arrayElement1, arrayElement2, arrayElement3, ... ]
The array contains javascript objects, so much like the document, we can drill down into the iframe located at array index 0.  We also can assign these values to variables that can be recalled later, just like in javascript, these variables become globally scoped objects that exist within the page.  Essentially, any javascript entered into the console is run as if it were written on the main page.  We can create, destroy and access all javascript objects which would normally be available to us through the <script> code employed on the page or in a linked javascript code snippet.
You will notice that the returned value from our variable definition is "undefined".  This is normal and expected. When we ask the console to display the value of our new variable, we see that it is the iframe we found previously, but because we pulled just the [0] element from the array, it is the iframe itself and not the array we looked at in the previous screenshot.

Getting to our DOMElement Object for the <textarea> field

What happens when we try to get our textarea object?  We know the id, because we hardcoded it as textarea_7129. Let's try to get it through the console using javascript:

We try two times to get the textarea, but both times we fail!  The function getElementById does not return any result, so we look by tag name and find only one textarea, but that textarea is not the one we defined.  We know that our textarea exists, because we can see the light green area on our screen; we can type into it; and we can manipulate it as we would any textarea element.

Our failure should not be too surprising if you remember one thing: iframe elements are containers of completely separate web pages.  Their content is not part of our current DOM, which means that it is not available through the document variable.

We must first get access to the correct document variable by asking the iframe to provide it.  Remember how we saved our iframe into the variable pageFrame?  We will use our pageFrame variable to interact with this other webpage:
Now we have success!  We first asked the iframe (through the pageFrame variable) to provide us a link to the contentDocument.  Then, we used that object to search for DOM elements having the id "textarea_7129", and we received one such element, which can now be modified as we see fit.

We can now use this element to set event handlers as well.  For example, let us create an event handler that shows us the event fired for every key down and key up event.  We will send the events to the console to be logged.  Type the following javascript into the console:
var ourTextArea = pageFrame.contentDocument.getElementById("textarea_7129");
ourTextArea.onkeydown = function(e){ console.log("KEY DOWN"); console.log( e ); }
ourTextArea.onkeyup = function(e){ console.log("KEY UP"); console.log(e); }
Now, when you type in the text area, you see the console fill with a series of KEY DOWN and KEY UP messages. When we look at the object listed immediately after the KEY DOWN and the KEY UP message, we see the contents of the event that was sent to the event handlers "onkeydown" and "onkeyup".  (NOTE: with javascript event handlers, the first argument is always the event itself.  You can add additional variables to these functions to gain access to other objects that are also passed to the handler. Even though these objects were passed, we chose not to have access to them, because we did not provide a variable name for javascript to attach the objects to.)

For my experiment with our new event handlers, I typed the following three key combinations into my text area:
  • j
  • Shift + j
  • Command + j
I'm using a Macintosh to do these tests.  You may not have a Command + j option if you are using a different system, but you might have another alternative like Window + j.  Let's look at the output in the console:

Understanding Our Output

If you are satisfied with just knowing how to attach events as we did above, then you can skip this section and move to the next.  The key combinations used in the example, reveal a fascinating and important fact about how events are triggered for onkeydown and onkeyup event handlers.  This section analyzes the output from our test and explains some important details about processing keyboard events.

For our very first character, we see a KEY DOWN and a KEY UP sequence from me pressing and releasing the "j" key.  Expanding the event shows that the key I pressed down is identified by keyCode 74 (equating to the "j" key), and that there were no other keys pressed at the same time.  Similarly, the key up event shows the same.

For our second character combination, I have four events.  Two KEY DOWN events, followed by two KEY UP events.  The first KEY DOWN event has a keyCode of 16, a keyIdentifier of "Shift" and shows that no other keys are pressed at the same time.  The second KEY DOWN event looks just like our lower-case j event, except that the field "shiftKey" now equals "true" instead of "false" as it did when I was just typing a lower-case j.  Next, we see the first KEY UP event, which is attached to the "J" key release, and after that we see the second KEY UP event, which is attached to the release of the "Shift" button.

For our third character combination, we see something very different from our SHIFT+J example.  We see two KEY DOWN events, but just one KEY UP events.  Did I release the keys simultaneously?

No. Actually, this is an important thing to notice. The "metaKey" named Command on Mac computers, actively suppresses the key up event on keys that are pressed while it is active.  So, we see a KEY DOWN for the pressing of the Command button identified with a "keyIdentifier" value of "Meta". Next, we see a KEY DOWN for our keyCode 74 ("J") key.  Then we see a KEY UP for our "Meta" key again.  There is no KEY UP for keyCode 74 in this instance.  Also, just as we could tell from the J event with the SHIFT example that the shift is currently depressed through the (BOOL)shiftKey value, we can tell if the meta key is depressed in this event example by looking at the (BOOL)metaKey value.  In this example, for the J event, "metaKey" = true.

For most keyboard keys on the USA layout, the keyCode relates directly to the ASCII value for the capital character on the key face.  ASCII 74 translates to "J".  This can be troublesome if you forget that if "shiftKey" is set to false on the event that we actually want a lower-case "j", which is ASCII 106.  It is up to the programmer to be responsible and check the values of shiftKey, metaKey, ctrlKey and altKey to determine the combination of keys that might be simultaneously depressed. And to respond to those key down and key up events which are triggered in the process of creating a key combination.

For a complete list of keyCodes and their corresponding ASCII values, refer to this handy reference provided by Adobe on their website: Tables of keyCodes and ASCII Values

Getting Values and Setting Values in our TextArea

A word of caution

Now that we know how to access our textarea with javascript, we can get and set values.  Before we continue, I must give a word of caution.  You probably already know this, but reminders are always nice: Browsers will render content in the DOM differently.

I am using Google Chrome for this example, so setting or changing the innerHTML value of my textarea has no effect on my displayed information.  However, in other browsers, I can use innerHTML to display the text value of the textarea on the page, or even to display editable formatted text.  It is because this behavior is not universal that we will look at creating editable text without a textarea element in the next section, but for now, we will play with our text area element in Google Chrome, and you will need to make adjustments to handle your browser of choice or before making any code for a production ready implementation which must handle all browser makes and models.

Getting and Setting Values

Now that we have stored our textarea DOMElement in the ourTextArea variable, we will use this to get and set values into our display.  Enter the following lines of code into the console and see how your text field responds. Enter them one at a time.

ourTextArea.innerHTML = "TEST HTML";
ourTextArea.outerHTML; //display the current tag plus the contents within
ourTextArea.innerHTML = "";

You will notice that after setting the innerHTML value our string "TEST HTML" appears between the open and close tags of the textarea element.  In Google Chrome, this does not affect the display.

ourTextArea.value = "TEST VALUE";
ourTextArea.outerHTML;
ourTextArea.value; //display the contents of this variable to the console log.
ourTextArea.value = "";

This example of manipulating value does have an effect in Google Chrome.  As of this writing, W3Schools reports that the textarea.value property is recognized by ALL major browsers. (textarea.value page at W3Schools)

ourTextArea.textContent = "TEST CONTENT";
ourTextArea.outerHTML;
ourTextArea.textContent; //display the contents of this variable to the console log.
ourTextArea.textContent = "";

According to W3Schools, textContent is utilized by browsers when dealing with XML pages (textContent page at W3Schools).  If you have chosen to use your WebView to display XML, then this might be more relevant to you.  W3Schools also recommends that the nodeValue should be used instead of textContent (nodeValue page at W3Schools). 

As you play with Google Chrome, entering these lines of text, you will notice that Google Chrome exposes the entire object and all that is available to you, which is how we stumble on things we might not have seen before, such as the textContent field.  I am always a proponent of playing with these objects in order to learn more about what they can do and how they are used.

Editable Text with Formatting!

If you are trying to create a more complex editor, and you do not wish to use Google's editor, implemented in the Cappuccino Object WKTextView (downloadable from git, and covered in this blog in a previous article on CPWebView), then you will need to stretch beyond the textarea element.  While textarea can display formatted text in some browsers, it fails in my favorite browser, so this gives us an excuse to play with events and iframes to create something different.

We are going to create a web view that has NO content, and we are going to put text into it.  First, replace the HTML definition used above with the following:
var textareaHTML = "<html><body></body></html>";
This just creates a basic iframe with a blank page.  We will reload our project, and use the console to make some changes.
How your page should look before proceeding.
On loading the program, we notice that our green area is gone and our text "Hello World!" is still partially covered by "something."  Our CPWebView is in the same exact location and frame as it was before.  The green is gone, because it was the textarea that was green, and we removed the textarea.  We also have lost the ability to type into our CPWebView, because the textarea element provided all that functionality.

What we have in front of us is a "world of possibilities."  We have a blank web page, which means that we can add text and remove text at will, and we can put it in any formatting that we choose.  Furthermore, we have complete formatting control, because (chances are) you are extremely familiar with HTML and CSS.

Using what we learned before, we can use javascript to prove this to ourselves.  Type the following into the console:
var frame = document.getElementsByTagName("iframe")[0];
//this only works, because we have only one webview, if there were more, we would need to cycle through them to find the correct one.var frameWindow = frame.contentWindow;
var frameDocument = frame.contentDocument;
var htmlBodyInFrame = frameDocument.getElementsByTagName("body")[0];
htmlBodyInFrame.innerHTML = "<h1>Header Formatted Title</h1><p>Body paragraph with <b>bold</b>, <i>italic</i>, and <u>underlined</u> text.</p><p style=\"font-size:18px;\">A paragraph at 18px font size.</p><hr/>";
 This produces:
We see that we can use javascript and the DOMElement.innerHTML property to set and get the value of the web view text with formatting.  This is fantastic, but it is not editable!  How can we use this as a text editor if we cannot type into the page?

We have several options, but the first thing that I want to consider is this: What if we consider the innerHTML text string a document that can be edited, and we use event handlers to record key presses and add to the string, which we then repopulate with each key stroke?

If we can do this, we can add text.  If we program our event handlers to recognize the backspace key as an indication to remove characters, then we can shorten that string element by element.

Let's try this.

Type the following into the console:
var htmlBodyInFrame = document.getElementsByTagName("iframe")[0].contentDocument.getElementsByTagName("body")[0];
var innerHTML = htmlBodyInFrame.innerHTML;
var keyPress = function( e, oldText ){
     var event = e || window.event; //for IE < 8 compatability
     var keyCode = event.keyCode;
     var shiftValue = ( event.shiftKey ? 0 : (97 - 65) );
     var shiftKey = event.shiftKey;
     //if shift is not pressed, we add the difference between "a" and "A" to get the lower case value of any character
     var letterOrNumPressed = ( event.keyCode >= 65 && event.keyCode <= 90 ? true : false );
     var backspace = ( event.keyCode == 8 ? true : false );
     var tabChar = (event.keyCode == 9 ? true : false );
     var enterChar = (event.keyCode == 13 ? true : false);
     var spaceBarPressed = (event.keyCode == 32 ? true : false );
     var punctuation = ((event.keyCode >= 186 && event.keyCode <=191) || (event.keyCode >= 219 && event.keyCode <=222) ? true : false );
     var ASCIIvalue = keyCode + shiftValue;
   
     /*****
       we will stop this event from going any further after it is processed, or else our backspace
       will be interpreted by the browser as a "Go Back" command.
     ******/
      //if this property does not exist, it will now be created.
    //debugger;
   
     e.cancelBubble = true;
     if( e.stopPropagation ){
        e.stopPropagation();          e.preventDefault();      }
     if( backspace ){
          return oldText.substr( 0, oldText.length - 1 );
     }else if( tabChar ){
          return oldText;
      }else if(enterChar){
          return oldText + "<br/>";
      }else if(spaceBarPressed){
          return oldText + "&nbsp;";
      }else if( letterOrNumPressed){
          return oldText + String.fromCharCode( ASCIIvalue );
      }else if( punctuation & !shiftKey ){
           var punctChar = "";
           switch( keyCode ){
               case 186:
                      //semicolon
                      punctChar = ";";
                      break;
               case 187:
                      //equal sign
                      punctChar = "=";
                      break;
               case 188:
                      //comma
                      punctChar = ",";
                      break;
               case 189:
                       //dash
                      punctChar = "-";
                      break;
               case 190:
                      //period
                      punctChar = ".";
                      break;
                case 191:
                       //forward slash
                       punctChar = "/";
                       break;
                case 192:
                       //grave accent
                       punctChar = "`";
                       break;
                 case 219:
                       //open bracket
                       punctChar = "[";
                       break;
                 case 220:
                       //close bracket
                        punctChar = "]";
                        break;
                  case 221:
                       //single quote
                       punctChar = "'";
                       break;
          }
          return oldText + punctChar;
      }else if( punctuation & shiftKey ){
           var punctChar = "";
           switch( keyCode ){
               case 186:
                      //shift semicolon
                      punctChar = ":";
                      break;
               case 187:
                      //shift equal sign
                      punctChar = "+";
                      break;
               case 188:
                      //shift comma
                      punctChar = "<";
                      break;
               case 189:
                       //shift dash
                      punctChar = "_";
                      break;
               case 190:
                      //shift period
                      punctChar = ">";
                      break;
                case 191:
                       //shift forward slash
                       punctChar = "?";
                       break;
                case 192:
                       //shift grave accent
                       punctChar = "~";
                       break;
                 case 219:
                       //shift open bracket
                       punctChar = "{";
                       break;
                 case 220:
                       //shift close bracket
                        punctChar = "}";
                        break;
                  case 221:
                       //shift single quote
                       punctChar = "\"";
                       break;
          }
          return oldText + punctChar;
      }else{
          return oldText;
      }
}
htmlBodyInFrame.onkeydown = function(e){
   innerHTML = keyPress( e, innerHTML );
   htmlBodyInFrame.innerHTML = innerHTML;
}
When we attempt to type into our non-editable web view with this new event handler, we get the following.  It's not perfect, but it is a huge start.  The text in my example image addresses some of the issues we currently face with the above implementation.

 This is much closer to a format friendly text area.  We know from previous examples that we can insert html into the innerHTML string and produce text that displays in a controlled HTML format.  We also now have the ability to type into a non-editable window and effect a stored string.  There are some problems with this implementation, which we will address in future page updates.  Until then, I am posting this for others to benefit from the tutorial as it stands in its current state.  Come back later and see how we have modified the tutorial to improve on this editable text area.

No comments:

Post a Comment