12. Finishing Touches: Some Global Attributes

In this last chapter, we will look at some seemingly insignificant global attributes of the HTMLElement interface. Our leitmotif in this chapter will be the development of a simple game that requires putting terms in a particular order following given criteria, similar to the preliminary round of the popular TV show Who Wants to Be a Millionaire. We call our game 1- 2-3-4! It involves the capital cities of the 27 EU member states. Can you put the capital cities in order by number of inhabitants? Do you know which city is farther north, south, west, or east? If not, you probably will by the end of this chapter.


Note

image

The game will consist of an HTML part, a script part, and a CSS part. All three components are of course available online for experimenting and inspecting. Here are the links:

http://html5.komplett.cc/code/chap_global/1234_en.html

http://html5.komplett.cc/code/chap_global/1234.js

http://html5.komplett.cc/code/chap_global/1234.css


12.1 News for the “class” Attribute

We first turn to a new DOM method of the HTMLElement interface, which allows us easy access to elements by the content of their respective class attribute: document.getElementsByClassName(). Its use could hardly be simpler and looks like this:

var questions = document.getElementsByClassName('q'),

This gives us a list ordered by position in the DOM tree of all elements whose class attribute contains the value q. If this list happens to consist of li elements with the names of the capital cities, the first step toward implementing our game is done: A live reference to the game objects is set in the variable questions. It reflects the current status of the individual li elements:

<li id=de class=q>Berlin</li>
<li id=at class=q>Vienna</li>
<!-- and 25 others -->

Access to the individual li elements can happen in two ways: either via the offset in the list or via a name, by which we do not mean the node content but the value of the existing id or name attribute:

questions.item(1).innerHTML           => Vienna
questions.namedItem('de').innerHTML   => Berlin

The length of the list can be found in questions.length, which means the offset for item(i) values can be between 0 and questions.length-1. Instead of an id attribute, elements with name attributes, for example form, can also be searched via namedItem(str) for values in this attribute.

If you want to search for several classes, you only need to pass the desired values during the method call, separated by spaces. Using the fictitious example of a fruit shop, searching for fruit defined as red or apple as their I like criteria could be successful with the following instruction:

var mmm = document.getElementsByClassName('red apple'),

This helps us find all red fruit, all apples, and of course also a red apple.

12.2 Defining Custom Attributes with “data-*”

Previously, it was not possible in HTML to freely define custom attributes within your application, but now the HTML specification offers a mechanism to achieve exactly that: the data-* attribute. Its use could not be simpler and only requires that the desired attribute has the prefix data-. There are few limitations for naming the attribute: It must be at least one character long and may not contain any uppercase letters. Using the data entry of one of the 27 capital cities of our game as an example, the data attributes for number of inhabitants, geographical location, and associated country could look like this:

<li id=at class=q
    data-pop=1705080
    data-geo-lat=48.20833
    data-geo-lng=16.373064
    data-country='Austria'>Vienna</li>

So how can you access your custom attributes? One option would be the classical method with getAttribute() and setAttribute(), but the specification has something better to offer: the dataset property. It allows for retrieving and setting all data attributes of an element via element.dataset:

var el  = q.namedItem('at'),
var pop = el.dataset.pop;     // 1705080
var lat = el.dataset.geoLat;  // 48.208
var lng = el.dataset.geoLng;  // 16.373
var ctr = el.dataset.country; // Austria
// and two years later perhaps ...
el.dataset.pop = 1717034;

By the time you read the third line, which contains el.dataset.geoLat, it will have become clear that hyphens have a special significance with data attributes; why else would data-geo-lat suddenly turn into dataset.geoLat. Hyphens are replaced by the next letter converted to uppercase—the special term for this way of capitalizing is called CamelCase. Now you know why no uppercase letters are allowed in data attributes: They could result in unexpected problems when replacing hyphens.

Unfortunately, support for element.dataset has not progressed well as yet. At the time of this writing, only WebKit had implemented the dataset DOM property in its Nightly builds. The game uses Remy Sharp’s html5-data.js as a workaround for this shortcoming, a JavaScript shim that at least enables the reading of data attributes. For setting, we must resort to the good old setAttribute() method.

12.3 The “hidden” Attribute

In the HTML Working Group, the hidden attribute caused a great stir. It managed to reach ISSUE status with a following Straw Poll for Objections and only got its final blessing through a decision of the HTML Working Group chairmen. The critics mainly claimed that hidden is superfluous. We will shortly demonstrate that the hidden attribute can indeed be useful, because selecting the questions for our game will be done via hidden. The algorithm is quickly explained: We first hide all items with hidden and then reveal four randomly selected items again. The relevant JavaScript code looks like this:

var showRandomNItems = function(q,n) {
  var show = [];
  for (var i=0; i<q.length; i++) {
    q.item(i).hidden = true;
    show.push(i);
  }
  show.sort(function() {return 0.5 – Math.random()});
  for (var i=0; i<n; i++) {
    q.item(show[i]).hidden = false;
  }
};

As arguments, we pass the list with li elements in the variable q and the desired number of elements to be shown in n to the function showRandomNItems(). We then hide all items with hidden=true and fill a new array with the indices of 0q.length. This array is then put in random order, and the desired number n of capital cities is revealed again.

12.4 The “classList” Interface

With getElementsByClassName(), we have already encountered the first option of working with the global class attribute. The classList interface is another one, allowing us to manage all values of a class attribute in a so-called DOMTokenList via the methods item(), contains(), add(), remove(), and toggle(). Let’s again use the example of the class attribute of a product in our fictitious fruit shop:

<li class="red apple">

Via li.classList, we then have the following properties:

li.classList.length                => 2
li.classList.item(0)               => red
li.classList.item(1)               => apple
li.classList.contains('red')       => true
li.classList.contains('apple')     => true
li.classList.contains('organic')   => false

If we forgot to attach the organic label during pricing, we can assign it afterward to our red organic apple:

li.classlist.add('organic')
li.classList.item(2) => organic

The banana on the next shelf that traveled all the way from Ecuador has wrongly been categorized as organic; we can easily fix that mistake:

banana.classList.remove('organic')

For bread, fresh in the morning and not quite as fresh in the evening, we could insert toggle() for showing the relevant state:

// freshly baked in the morning
bread.classlist.add('fresh')
// late afternoon
bread.classList.toggle('fresh')
bread.classList.contains('fresh')   => false
// and the next morning after the new delivery
bread.classList.toggle('fresh')
bread.classList.contains('fresh')   => true

In the 1-2-3-4! game we will use classList for displaying correct or wrong for the selected order. Before turning to the core of the game, the drag-and-drop function, we will quickly adapt the game’s layout and add four areas to the left of the city list where the cities can be sorted. All four li elements get the class a for answer during the selection, analog to q for question, and Unicode symbols for numbering in the range &#2776; to &#2779;—so-called DINGBAT NEGATIVE CIRCLED DIGITS:

<ol>
<li class=a>&#x2776;</li>
<li class=a>&#x2777;</li>
<li class=a>&#x2778;</li>
<li class=a>&#x2779;</li>
</ol>

With a few additional CSS formats, we finalize the static basic version of the game. Figure 12.1 shows the basic layout. The online version can be found at http://html5.komplett.cc/code/chap_global/1234_static_en.html.

Figure 12.1 Static basic layout of the 1-2-3-4! game

image


Note

image

As you can see in the title bar in Figure 12.1, the screen shot was created with a beta version of Firefox 4, because only that browser version meets the requirements of the game. With the exception of data-*, for which we use Remy Sharp’s JavaScript shim as mentioned earlier, all necessary attributes and methods are implemented in this version.


12.5 Drag and Drop with the “draggable” Attribute

Drag and drop in the browser is really nothing new. This function has been present in Internet Explorer (IE) since 1999, in version 5.0 at that time. Based on the IE implementation, drag and drop was then included in the specification in 2005 and is now available in all common browsers, with the exception of Opera.

The checklist for the implementation of a classic drag-and-drop operation, as used in the game for sorting the cities by number of inhabitants, involves the following tasks:

1. Selecting elements that can be dragged

2. Determining data to be dragged along in the background as soon as the drag-and-drop operation is started

3. Deciding where the dragged element can be dropped

4. Extracting the data as soon as the user ends the drag-and-drop operation on a valid target object

We can fulfill the first task with the global draggable attribute. Via draggable=true it marks the relevant element as a candidate for dragging to another position. Two HTML elements are by default defined as draggable: the img element and the a element, provided it has an href attribute, which made it possible previously to drag links or images on the desktop and save them easily. If we wanted to prevent drag and drop in these elements, we could use draggable=false.

To prepare an entry in the city list for drag and drop, we first need to add the draggable attribute and set it to true:

<li id=be draggable=true>Brussels</li>

Drag-and-drop operations are not an end unto themselves but a means to an end: Their purpose is to transfer information from one place to another. Which information this is must be determined at the start of the drag operation in question, which is why we add a dragstart event handler to each item in our city list. It calls the callback function startDrag() and passes it the so-called DragEvent in the argument event:

<li id=be draggable=true
    ondragstart="startDrag(event)">Brussels</li>

This DragEvent plays a central role in drag and drop, because its readonly attribute dataTransfer gives us access to the DataTransfer interface of the drag-and-drop API, where all necessary methods and attributes of drag and drop are available. One of these methods is setData(format, data). This determines which data is to be dragged along in the background when dragging from A to B. In our case, it is the ID in the format text. With this we will later be able to access the original data:

var dragStart = function(evt) {
  evt.dataTransfer.setData('text',evt.target.id);
};

From this point on, the list item can be dragged—where we will drop it remains open. It would be helpful to have a droppable attribute available in parallel to the draggable attribute, but this is not the case, which is why we require no less than three events for successful dropping: dragenter, dragover, and drop. Strangely enough, two of them must be aborted in order for the third and most important event to be fired. The HTML code for one of the list items on the left of the game, where the cities are arranged, shows us which ones they are

<li ondragenter="return false;"
    ondragover="return false;"
    ondrop="drop(event)">&#x2776;</li>

The two events dragenter and dragover exist primarily to signal: You can drop here! In our case, they are immediately aborted with return false. If we were to use two callback functions, we could offer additional user feedback, for example: You can drop here! for dragenter or Are you sure you got it right? for dragover. To abort the event in the callback function, we do not use return false, but instead use evt.preventDefault(). The effect is the same; it fires the drop event.

This brings us to the last task of the checklist, extracting previously set data and implementing the game logic with the ondrop event. We again pass the DragEvent in the argument event to the callback function drop() and then use getData() to access the ID saved at dragstart:

var drop = function(evt) {
  var id = evt.dataTransfer.getData('text'),
  var elemQ = questions.namedItem(id);
  var elemA = evt.target;
  elemA.setAttribute("data-id",id);
  elemA.setAttribute("data-pop",elemQ.dataset.pop);
  elemA.innerHTML = elemQ.innerHTML;
  // continue game logic
};

Via the ID, we can use questions.namedItem(id) to directly access the source object, store its number of inhabitants as a data attribute in the target object, and use its city name as a label. The two variables elemQ and elemA are shortcuts for the two li elements involved. Remember that Remy Sharp’s JavaScript shim for data attributes unfortunately works only for read access, so we use the familiar elamA.setAttribute("data-id",id) for saving the values instead of the more elegant elemA.dataset.id=id.

As part of the game logic, the two buttons concerned are also deactivated at this point and visual feedback is given—in both cases via CSS classes, which we can conveniently add via classList.add(). The additional items in the function drop() are

elemQ.classList.add('qInactive'),
elemA.classList.add('aInactive'),

The corresponding formats in the CSS stylesheet are as follows:

.qInactive {
  pointer-events: none;
  color: #AAA;
  background-color: #EEE;
  border-color: #AAA;
}
.aInactive {
  pointer-events: none;
  background-color: hsl(60,100%,85%);
  border-color: hsl(60,100%,40%);
}

At this point in the game, we check whether all cities have been assigned in the order of their number of inhabitants. Correct answers are highlighted in green. Incorrect answers are removed and can then be arranged once more. For the color change in correct answers, we again use classList.add(); the corresponding CSS format looks like this:

.aCorrect {
  background-color: hsl(75,100%,85%);
  border-color: hsl(75,100%,40%);
}

As soon as all answers are correct, the player is congratulated on his or her success, and if the player clicks the RESTART button, four other randomly selected cities are offered for another game. If a user finds numbers of inhabitants too tedious, the user can select two other game modes from the pull-down menu: arranging the cities by geographical location from North to South or East to West. For details on the JavaScript and CSS implementation, see these links:

http://html5.komplett.cc/code/chap_global/1234.js

http://html5.komplett.cc/code/chap_global/1234.css

You can see the completed game in action in Figure 12.2. If you would like to expand the game, you can go right ahead and implement an expansion Select number of cities! The static list on the left should then be generated dynamically. Have fun!

Figure 12.2 The game “1-2-3-4!” in action

image

Let’s get back to our original topic, drag and drop. After this simple and practical example, several details are still open—for example, three other events available for drag and drop operations: drag, dragend, and dragleave. During dragging, a drag event is created at an interval of 350 ms (±200 ms); dropping creates a dragend event. The third event, dragleave, concerns the target object and occurs when leaving a potential drop zone.

The DataTransfer object also provides interesting methods and attributes—for example, the method setDragImage(element, x, y) with which we can display a custom image during dragging to provide feedback. A similar effect can be achieved with addElement(element), but this time we can drag along not just an image, but whole sections of a page as a feedback indicator.

With dataTransfer.types, we can return a DOMStringList of all formats and their values that were assigned with setData() at the startdrag event. In our game this list was short and contained only one item with the ID in the format text, interpreted automatically by the browser as text/plain. The format is not completely restricted to using MIME types; the specification also allows formats that do not correspond to a MIME type. So we could have used all data attributes with speaking names as a format. Using the example of the ID and the number of inhabitants, this would look as follows:

evt.dataTransfer.setData('id',evt.target.id);
evt.dataTransfer.setData('pop',evt.target.dataset.pop);

Retrieving them at a later time would then have been easier via getData('id') or getData('pop').


Tip

image

When dragging elements with microdata attributes, all values are automatically taken along as a JSON character string. You can access them easily via getData('application/microdata+json').


If we decide to remove certain formats from the list during the drag-and-drop operation, we can use the method clearData(format) to delete the specified format. If we omit format altogether, all existing formats are deleted.

The two DataTransfer attributes effectAllow and dropEffect sound promising, hinting at appealing optical effects during dragging and dropping. On closer inspection it becomes clear that they only serve to control the appearance of the cursor while entering the drop zone. Permitted keywords for dropEffect are copy, link, move, and none. They add a plus symbol, link symbol, arrow, or nothing (if none is selected) to the cursor during the dragenter event. With a small application (see Figure 12.3), you can test the behavior of your browser online at http://html5.komplett.cc/code/chap_global/dropEffect_en.html.

Figure 12.3 Test application of the “dataTransfer.dropEffect”

image

The value of the dropEffect attribute can be changed in any phase of the drag-and-drop action, but it must always correspond to the value specified previously in effectAllow. In addition to copy, link, move, and none, effectAllow also permits combinations, such as copyLink, copyMove, or linkMove, marking both components as valid. Via the keyword all, you can also allow all effects.

Before we move on to the next section, here are a few closing thoughts on security issues with drag and drop: Data in the DataTransfer object is only made available to the script again at the drop event. So, while dragging a document from A to B, data is prevented from being intercepted by a malicious document C. For the same reason, the drop event must be explicitly triggered by the user by dropping the object, not automatically by the script. Even the script-controlled moving of the window underneath the mouse position must not fire a dragStart event; otherwise, sensitive data could be dragged into malicious third-party documents against the user’s will.

Drag and drop in the browser opens a wealth of new possibilities. If you are looking for an impressive example of combining drag and drop with Canvas, localStorage, offline cache, and other techniques associated with HTML5, such as XMLHttpRequest or the FileAPI, do not miss Paul Rouget’s blog, an HTML5 offline image editor and uploader application, with its four-minute video. Even though it is only meant to be a showcase for features in Firefox 3.6, it does show in an impressive manner what is already possible now. Check it out at http://hacks.mozilla.org/2010/02/an-html5-offline-image-editor-and-uploader-application.

Now, we’ll look closer at one aspect of this demo, introducing you to drag and drop in a document and extracting data from the dragged file via the FileAPI.

12.5.1 Drag and Drop in Combination with the “FileAPI”

Figure 12.4 shows a screen shot of the application we will develop in this section based on drag and drop and the FileAPI. It allows us to drag locally saved images taken with a digital camera or a mobile device directly into the browser and then make parts of their EXIF information visible. The necessary files are again available online at:

http://html5.komplett.cc/code/chap_global/extract_exif_en.html

http://html5.komplett.cc/code/chap_global/extract_exif.js

http://html5.komplett.cc/code/chap_global/extract_exif.css

http://html5.komplett.cc/code/chap_global/lib/exif.js

http://html5.komplett.cc/code/chap_global/images/senderstal.jpg

Figure 12.4 Drag and drop in combination with “FileAPI”

image

Let’s begin by preparing the drop zone. You can see it on the right in the screen shot of Figure 12.4. It consists of the Unicode symbol PREVIOUS PAGE (&#x2397;), some CSS instructions, and the event listener attributes required for drag and drop:

<div ondragenter="return false;"
     ondragover="return false;"
     ondrop="drop(event)">&#x2397;</div>

As soon as an image is dragged from the desktop to this area, the dropped image can be accessed in the callback function drop() via the dataTransfer object:

var drop = function(evt) {
  var file = evt.dataTransfer.files[0];
};

From now on we are within the FileAPI, because the attribute files represents a so-called FileList object that is an array of all file objects involved in the current drag-and-drop operation. Although the demo by Paul Rouget allows the loading of several images simultaneously, you can only drop one image at a time into the drop zone in our example. So the reference to this file is always to be found in files[0].

For the thumbnail of the image, we use a data: URI as a src attribute, created via the FileAPI, as discussed in Chapter 5, Canvas (see section 5.12, Base64 encoding with “canvas.toDataURL()”). We first define a new FileReader object, and then load the image asynchronously into the memory via readAsDataURL(). At the end of the loading process, we assign the resulting data: URI to the image as a src attribute. The relevant JavaScript code is short and clear:

var dataURLReader = new FileReader();
dataURLReader.onloadend = function() {
  imgElem.src = dataURLReader.result;
  imgInfo.innerHTML = file.name+' ('+_inKb(file.size)+')';
}
dataURLReader.readAsDataURL(file);

The width of the thumbnail is specified in the CSS stylesheet as width: 250px; the height is adjusted automatically by the browser. The text below the image reflects the FileAPI attributes file.name and file.size. The byte information in file.size must be divided by 1024 to convert the file size to kilobytes. The auxiliary function _inKb() does this for us and also adds the characters KB at the end of the calculated value.

For reading the EXIF information, the file must be in binary form. Similar to readAsDataURL(), we now use readAsBinaryString() and get our desired result in the onload callback. This does not yet allow us to access the EXIF data, because the data is hiding somewhere in the binary code and needs to be extracted first. We want to thank Jacob Seidelin for his JavaScript implementation for reading EXIF data, which made this example possible.


Note

image

The version of exif.js used in this example is not the original version by Jacob Seidelin, but instead is a slightly adapted version by Paul Rouget. You can find both versions online in the relevant demos at these URLs:

http://www.nihilogic.dk/labs/exif

http://demos.hacks.mozilla.org/openweb/FileAPI


A single line is now sufficient to find the existing EXIF information as key-value pairs via the function findEXIFinJPEG(). In a for loop, this list is then processed and converted into table rows with the auxiliary function _asRow(), and the result is added to the result table in the variable exifInfo:

var binaryReader = new FileReader();
binaryReader.onload = function() {
  var exif = findEXIFinJPEG(binaryReader.result);
  for (var key in exif) {
    exifInfo.innerHTML += _asRow(key,exif[key]);
  }
};
binaryReader.readAsBinaryString(file);

As you can see in the screen shot in Figure 12.4, only selected EXIF info is listed in our example. Apart from information about camera type, date and time, exposure time, ISO speed, use of flash, or image dimensions, there are even GPS coordinates that were recorded by the camera when taking the picture. A glance at the coordinates and the image name reveals the location: the Senderstal valley near the Kalkkögel in the Stubai Alps (southwest of Innsbruck, Tyrol, Austria). The prominent peak in the center of the image is called Schwarzhorn.


Tip

image

If you want to display all EXIF information of your own images while testing the application shown in Figure 12.4, you simply need to remove the comment characters from the item //showTags = '*' in the file extract_exif.js!


Although the FileAPI specification is rather short, it offers several interesting features. In addition to the already familiar methods for reading files in binary mode or as data: URI, you have the option of reading text files via readAsText(). The onprogress event serves as user feedback for implementing a progress display during loading, and if loading takes too long, you can also abort it with abort(). Additionally, the FileAPI can also be used for forms via <input type=file>.

The same applies here as for drag and drop: If you want to implement more complex applications, you will have to study the details in the specification. The relevant contents for the FileAPI and drag and drop can be found at these links:

http://www.w3.org/TR/FileAPI

http://www.w3.org/TR/html5/dnd.html

After this excursion into the world of the FileAPI, there are still two interesting global attributes that we want to mention in this chapter. Similar to drag and drop, they open up a new and unknown world, only encountered previously through word-processing programs. Who would have thought a few years ago that the content of an HTML page could be edited directly in the browser and the spelling checked immediately?

12.6 The Attributes “contenteditable” and “spellcheck”

HTML pages can be made editable via the contenteditable attribute, but of course the changes occur only in memory. For filling in an online form before printing it, this can be very useful, and there are surely fields of application within the intranet as well, especially if amended content is written back with scripts. We do not want to go quite that far in this section; instead, we will merely demonstrate how contenteditable can be activated. The syntax in the HTML code is simple:

<p contenteditable=true>
  Text to be edited ...
</p>

The editable area is highlighted by clicking on the paragraph, and a flashing cursor appears in the text. You can then use hotkeys or the context menu to cut, paste, copy, or delete content, just as in a text editor, and all actions can also be undone step by step. If we want to also activate the spell check, we need to add the attribute spellcheck and set it to true:

<p contenteditable=true spellcheck=true>
    Text to be edited ...
</p>

The specification does not define how the spell check should be carried out in detail; this is up to the individual browser. Using the example of Firefox 3.6, Figure 12.5 shows what such an implementation could look like. The example is of course also available for testing online at http://html5.komplett.cc/code/chap_global/edit_page_en.html.

Figure 12.5 Editing a page with the spell checker in Firefox 3.6

image

The screen shot in Figure 12.5 shows how misspelled words as well as unknown words are indicated with red wavy lines. The context menu allows you to switch to another language, install new dictionaries, and even correct mistakes by choosing from suggestions. Unknown words can also be added to a personal dictionary.

In Firefox, personal dictionaries are located in the user’s profile folder and are named persdict.dat. Even though the file extension suggests otherwise, these files are pure text documents with one word per line. Unfortunately, entries from personal dictionaries are not yet listed during correction, at least with Firefox 3.6.

At the time of this writing, no browser had implemented the spellcheck attribute without errors. It seems that browsers view all text areas of a page as natural candidates for spell checking and always allow checking in the context menu without taking into account the spellcheck attribute. The attempt to exclude the CSS code from spell checking via spellcheck=false was unsuccessful in all of the browsers tested.

Figure 12.6 shows that not only text components, but also CSS styles and even images can be made editable.

Figure 12.6 “Live” editing of styles and image sizes

image

The editing options for images are as yet not very impressive. Firefox allows for at least changing the image size by dragging eight available anchor points. The idea of changing styles live within a style element is more exciting. This idea comes from Anne van Kesteren who first demonstrated this effect via a simple trick (see http://bit.ly/dtnyIJ). As with Anne van Kesteren’s example, the style element in our application is first made visible with display:block and then editable with contenteditable=true. The result is astonishing. Changes become effective immediately. In our case, after changing the CSS instruction for the code element, the corresponding objects appear in the named color teal with font-size 180%. Try it out!

Summary

Seven selected global attributes, some of them new, and their JavaScript APIs are the focus of the final chapter of this book. We encounter five of them in more detail while developing our 1-2-3-4! game. The starting point is a new method for the class attribute: the classList interface. It drastically simplifies manipulating the individual class components. The same applies to the dataset property, enabling easier management of custom, user-defined attributes that are marked with the special prefix data-*.

Our game incorporates the highly controversial hidden attribute, plus one of the key features of HTML5: drag and drop. The draggable attribute, a handful of events, and the DataTransfer interface enable not only dragging and dropping elements within a browser, but also interaction with the underlying operating system. The impressive example for reading EXIF information of digital images uses this feature and introduces the FileAPI.

The final section of the chapter demonstrates that text content and even CSS formats of an HTML5 page can be directly edited in the browser. To avoid spelling errors during editing, the spellcheck attribute can be used to activate spell checking in the browser, complete with a dictionary. Could HTML5 be on its way to turning into a full-blown office package?

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset