Chapter 13: Browser Events
¶ To add interesting functionality to a web-page, just being able to
inspect or modify the document is generally not enough. We also need
to be able to detect what the user is doing, and respond to it. For
this, we will use a thing called event handlers. Pressed keys are
events, mouse clicks are events, even mouse motion can be seen as a
series of events. In chapter 11, we added an onclick
property to a
button, in order to do something when it was pressed. This is a simple
event handler.
¶ The way browser events work is, fundamentally, very simple. It is possible to register handlers for specific event types and specific DOM nodes. Whenever an event occurs, the handler for that event, if any, is called. For some events, such as key presses, knowing just that the event occurred is not good enough, you also want to know which key was pressed. To store such information, every event creates an event object, which the handler can look at.
¶ It is important to realise that, even though events can fire at any
time, no two handlers ever run at the same moment. If other JavaScript
code is still running, the browser waits until it finishes before it
calls the next handler. This also holds for code that is triggered in
other ways, such as with setTimeout
. In programmer jargon, browser
JavaScript is single-threaded, there are never two 'threads'
running at the same time. This is, in most cases, a good thing. It is
very easy to get strange results when multiple things happen at the
same time.
¶ An event, when not handled, can 'bubble' through the DOM tree. What
this means is that if you click on, for example, a link in a
paragraph, any handlers associated with the link are called first. If
there are no such handlers, or these handlers do not indicate that
they have finished handling the event, the handlers for the paragraph,
which is the parent of the link, are tried. After that, the handlers
for document.body
get a turn. Finally, if no JavaScript handlers
have taken care of the event, the browser handles it. When clicking a
link, this means that the link will be followed.
¶ So, as you see, events are easy. The only hard thing about them is
that browsers, while all supporting more or less the same
functionality, support this functionality through different
interfaces. As usual, the most incompatible browser is Internet
Explorer, which ignores the standard that most other browsers follow.
After that, there is Opera, which does not properly support some
useful events, such as the onunload
event which fires when leaving a
page, and sometimes gives confusing information about keyboard events.
¶ There are four event-related actions one might want to take.
- Registering an event handler.
- Getting the event object.
- Extracting information from this object.
- Signalling that an event has been handled.
¶ None of them work the same across all major browsers.
¶ As a practice field for our event-handling, we open a document with a button and a text field. Keep this window open (and attached) for the rest of the chapter.
attach(window.open("example_events.html"));
¶ The first action, registering a handler, can be done by setting an
element's onclick
(or onkeypress
, and so on) property. This does
in fact work across browsers, but it has an important drawback ― you
can only attach one handler to an element. Most of the time, one is
enough, but there are cases, especially when a program has to be able
to work together with other programs (which might also be adding
handlers), that this is annoying.
¶ In Internet Explorer, one can add a click handler to a button like this:
$("button").attachEvent("onclick", function(){print("Click!");});
¶ On the other browsers, it goes like this:
$("button").addEventListener("click", function(){print("Click!");}, false);
¶ Note how "on"
is left off in the second case. The third argument
to addEventListener
, false
, indicates that the event should
'bubble' through the DOM tree as normal. Giving true
instead can be
used to give this handler priority over the handlers 'beneath' it, but
since Internet Explorer does not support such a thing, this is rarely
useful.
¶ Write a function called registerEventHandler
to wrap the
incompatibilities of these two models. It takes three arguments: first
a DOM node that the handler should be attached to, then the name of
the event type, such as "click"
or "keypress"
, and finally the
handler function.
¶ To determine which method should be called, look for the methods
themselves ― if the DOM node has a method called attachEvent
, you
may assume that this is the correct method. Note that this is much
preferable to directly checking whether the browser is Internet
Explorer. If a new browser arrives which uses Internet Explorer's
model, or Internet Explorer suddenly switches to the standard model,
the code will still work. Both are rather unlikely, of course, but
doing something in a smart way never hurts.
function registerEventHandler(node, event, handler) { if (typeof node.addEventListener == "function") node.addEventListener(event, handler, false); else node.attachEvent("on" + event, handler); } registerEventHandler($("button"), "click", function(){print("Click (2)");});
¶ Don't fret about the long, clumsy name. Later on, we will have to add an extra wrapper to wrap this wrapper, and it will have a shorter name.
¶ It is also possible to do this check only once, and define
registerEventHandler
to hold a different function depending on the
browser. This is more efficient, but a little strange.
if (typeof document.addEventListener == "function") var registerEventHandler = function(node, event, handler) { node.addEventListener(event, handler, false); }; else var registerEventHandler = function(node, event, handler) { node.attachEvent("on" + event, handler); };
¶ Removing events works very much like adding them, but this time the
methods detachEvent
and removeEventListener
are used. Note
that, to remove a handler, you need to have access to the function you
attached to it.
function unregisterEventHandler(node, event, handler) { if (typeof node.removeEventListener == "function") node.removeEventListener(event, handler, false); else node.detachEvent("on" + event, handler); }
¶ Exceptions produced by event handlers can, because of technical limitations, not be caught by the console. Thus, they are handled by the browser, which might mean they get hidden in some kind of 'error console' somewhere, or cause a message to pop up. When you write an event handler and it does not seem to work, it might be silently aborting because it causes some kind of error.
¶ Most browsers pass the event object as an argument to the
handler. Internet Explorer stores it in the top-level variable called
event
. When looking at JavaScript code, you will often come across
something like event || window.event
, which takes the local variable
event
or, if that is undefined, the top-level variable by that same
name.
function showEvent(event) { show(event || window.event); } registerEventHandler($("textfield"), "keypress", showEvent);
¶ Type a few characters in the field, look at the objects, and shut it up again:
unregisterEventHandler($("textfield"), "keypress", showEvent);
¶ When the user
clicks his mouse, three events are generated. First mousedown
, at
the moment the mouse button is pressed. Then, mouseup
, at the
moment it is released. And finally, click
, to indicate something
was clicked. When this happens two times in quick succession, a
dblclick
(double-click) event is also generated. Note that it is
possible for the mousedown
and mouseup
events to happen some time
apart ― when the mouse button is held for a while.
¶ When you attach an event handler to, for example, a button, the fact
that it has been clicked is often all you need to know. When the
handler, on the other hand, is attached to a node that has children,
clicks from the children will 'bubble' up to it, and you will want to
find out which child has been clicked. For this purpose, event objects
have a property called target
... or srcElement
, depending on the
browser.
¶ Another interesting piece of information
are the precise coordinates at which the click occurred. Event objects
related to the mouse contain clientX
and clientY
properties,
which give the x
and y
coordinates of the mouse, in pixels, on the
screen. Documents can scroll, though, so often these coordinates do
not tell us much about the part of the document that the mouse is
over. Some browsers provide pageX
and pageY
properties for
this purpose, but others (guess which) do not. Fortunately, the
information about the amount of pixels the document has been scrolled
can be found in document.body.scrollLeft
and
document.body.scrollTop
.
¶ This handler, attached to the whole document, intercepts all mouse clicks, and prints some information about them.
function reportClick(event) { event = event || window.event; var target = event.target || event.srcElement; var pageX = event.pageX, pageY = event.pageY; if (pageX == undefined) { pageX = event.clientX + document.body.scrollLeft; pageY = event.clientY + document.body.scrollTop; } print("Mouse clicked at ", pageX, ", ", pageY, ". Inside element:"); show(target); } registerEventHandler(document, "click", reportClick);
¶ And get rid of it again:
unregisterEventHandler(document, "click", reportClick);
¶ Obviously, writing all these checks and workarounds is not something you want to do in every single event handler. In a moment, after we have gotten acquainted with a few more incompatibilities, we will write a function to 'normalise' event objects to work the same across browsers.
¶ It is also sometimes possible to find out which mouse button was
pressed, using the which
and button
properties of event
objects. Unfortunately, this is very unreliable ― some browsers
pretend mouses have only one button, others report right-clicks as
clicks during which the control key was held down, and so on.
¶ Apart from clicks, we
might also be interested in the movement of the mouse. The
mousemove
event of a DOM node is fired whenever the mouse moves
while it is over that element. There are also mouseover
and
mouseout
, which are fired only when the mouse enters or leaves a
node. For events of this last type, the target
(or srcElement
)
property points at the node that the event is fired for, while the
relatedTarget
(or toElement
, or fromElement
) property gives
the node that the mouse came from (for mouseover
) or left to (for
mouseout
).
¶ mouseover
and mouseout
can be tricky when they are registered on
an element that has child nodes. Events fired for the child nodes will
bubble up to the parent element, so you will also see a mouseover
event when the mouse enters one of the child nodes. The target
and
relatedTarget
properties can be used to detect (and ignore) such
events.
¶ For every key that the user
presses, three events are generated: keydown
, keyup
, and
keypress
. In general, you should use the first two in cases where
you really want to know which key was pressed, for example when you
want to do something when the arrow keys are pressed. keypress
, on
the other hand, is to be used when you are interested in the character
that is being typed. The reason for this is that there is often no
character information in keyup
and keydown
events, and Internet
Explorer does not generate a keypress
event at all for special keys
such as the arrow keys.
¶ Finding out which key was pressed can be quite a challenge by itself.
For keydown
and keyup
events, the event object will have a
keyCode
property, which contains a number. Most of the time, these
codes can be used to identify keys in a reasonably browser-independent
way. Finding out which code corresponds to which key can be done by
simple experiments...
function printKeyCode(event) { event = event || window.event; print("Key ", event.keyCode, " was pressed."); } registerEventHandler($("textfield"), "keydown", printKeyCode);
unregisterEventHandler($("textfield"), "keydown", printKeyCode);
¶ In most browsers, a single key code corresponds to a single physical key on your keyboard. The Opera browser, however, will generate different key codes for some keys depending on whether shift is pressed or not. Even worse, some of these shift-is-pressed codes are the same codes that are also used for other keys ― shift-9, which on most keyboards is used to type a parenthesis, gets the same code as the down arrow, and as such is hard to distinguish from it. When this threatens to sabotage your programs, you can usually resolve it by ignoring key events that have shift pressed.
¶ To find out whether the shift, control, or alt key was held during a
key or mouse event, you can look at the shiftKey
, ctrlKey
, and
altKey
properties of the event object.
¶ For keypress
events, you will want to know which character was
typed. The event object will have a charCode
property, which, if
you are lucky, contains the Unicode number corresponding to the
character that was typed, which can be converted to a 1-character
string by using String.fromCharCode
. Unfortunately, some browsers
do not define this property, or define it as 0
, and store the
character code in the keyCode
property instead.
function printCharacter(event) { event = event || window.event; var charCode = event.charCode; if (charCode == undefined || charCode === 0) charCode = event.keyCode; print("Character '", String.fromCharCode(charCode), "'"); } registerEventHandler($("textfield"), "keypress", printCharacter);
unregisterEventHandler($("textfield"), "keypress", printCharacter);
¶ An event handler can 'stop' the event it is handling. There are two different ways to do this. You can prevent the event from bubbling up to parent nodes and the handlers defined on those, and you can prevent the browser from taking the standard action associated with such an event. It should be noted that browsers do not always follow this ― preventing the default behaviour for the pressing of certain 'hotkeys' will, on many browsers, not actually keep the browser from executing the normal effect of these keys.
¶ On most browsers, stopping event bubbling is done with the
stopPropagation
method of the event object, and preventing default
behaviour is done with the preventDefault
method. For Internet
Explorer, this is done by setting the cancelBubble
property of
this object to true
, and the returnValue
property to false
,
respectively.
¶ And that was the last of the long list of incompatibilities that we will discuss in this chapter. Which means that we can finally write the event normaliser function and move on to more interesting things.
function normaliseEvent(event) { if (!event.stopPropagation) { event.stopPropagation = function() {this.cancelBubble = true;}; event.preventDefault = function() {this.returnValue = false;}; } if (!event.stop) { event.stop = function() { this.stopPropagation(); this.preventDefault(); }; } if (event.srcElement && !event.target) event.target = event.srcElement; if ((event.toElement || event.fromElement) && !event.relatedTarget) event.relatedTarget = event.toElement || event.fromElement; if (event.clientX != undefined && event.pageX == undefined) { event.pageX = event.clientX + document.body.scrollLeft; event.pageY = event.clientY + document.body.scrollTop; } if (event.type == "keypress") { if (event.charCode === 0 || event.charCode == undefined) event.character = String.fromCharCode(event.keyCode); else event.character = String.fromCharCode(event.charCode); } return event; }
¶ A stop
method is added, which cancels both the bubbling and
the default action of the event. Some browsers already provide this,
in which case we leave it as it is.
¶ Next we can write convenient wrappers for registerEventHandler
and
unregisterEventHandler
:
function addHandler(node, type, handler) { function wrapHandler(event) { handler(normaliseEvent(event || window.event)); } registerEventHandler(node, type, wrapHandler); return {node: node, type: type, handler: wrapHandler}; } function removeHandler(object) { unregisterEventHandler(object.node, object.type, object.handler); } var blockQ = addHandler($("textfield"), "keypress", function(event) { if (event.character.toLowerCase() == "q") event.stop(); });
¶ The new addHandler
function wraps the handler function it is given
in a new function, so it can take care of normalising the event
objects. It returns an object that can be given to removeHandler
when we want to remove this specific handler. Try typing a 'q
' in
the text field.
removeHandler(blockQ);
¶ Armed with addHandler
and the dom
function from the last chapter,
we are ready for more challenging feats of document-manipulation. As
an exercise, we will implement the game known as Sokoban. This is
something of a classic, but you may not have seen it before. The rules
are this: There is a grid, made up of walls, empty space, and one or
more 'exits'. On this grid, there are a number of crates or stones,
and a little dude that the player controls. This dude can be moved
horizontally and vertically into empty squares, and can push the
boulders around, provided that there is empty space behind them. The
goal of the game is to move a given number of boulders into the exits.
¶ Just like the terraria from chapter 8, a Sokoban level can be represented
as text. The variable sokobanLevels
, in the example_events.html
window, contains an array of level objects. Each level has a property
field
, containing a textual representation of the level, and a
property boulders
, indicating the amount of boulders that must be
expelled to finish the level.
show(sokobanLevels.length); show(sokobanLevels[1].boulders); forEach(sokobanLevels[1].field, print);
¶ In such a level, the #
characters are walls, spaces are empty
squares, 0
characters are used for for boulders, an @
for the
starting location of the player, and a *
for the exit.
¶ But, when playing the game, we do not want to be looking at this textual representation. Instead, we will put a table into the document. I made small style-sheet (sokoban.css, if you are curious what it looks like) to give the cells of this table a fixed square size, and added it to the example document. Each of the cells in this table will get a background image, representing the type of the square (empty, wall, or exit). To show the location of the player and the boulders, images are added to these table cells, and moved to different cells as appropriate.
¶ It would be possible to use this table as the main representation of
our data ― when we want to look whether there is a wall in a given
square, we just inspect the background of the appropriate table cell,
and to find the player, we just search for the image node with the
correct src
property. In some cases, this approach is practical, but
for this program I chose to keep a separate data structure for the
grid, because it makes things much more straightforward.
¶ This data structure is a two-dimensional grid of objects, representing the squares of the playing field. Each of the objects must store the type of background it has and whether there is a boulder or player present in that cell. It should also contain a reference to the table cell that is used to display it in the document, to make it easy to move images in and out of this table cell.
¶ That gives us two kinds of objects ― one to hold the grid of the
playing field, and one to represent the individual cells in this grid.
If we want the game to also do things like moving the next level at
the appropriate moment, and being able to reset the current level when
you mess up, we will also need a 'controller' object, which creates or
removes the field objects at the appropriate moment. For convenience,
we will be using the prototype approach outlined at the end of chapter 8,
so object types are just prototypes, and the create
method, rather
than the new
operator, is used to make new objects.
¶ Let us start with the objects representing the squares of the game's
field. They are responsible for setting the background of their cells
correctly, and adding images as appropriate. The img/sokoban/
directory contains a set of images, based on another ancient game,
which will be used to visualise the game. For a start, the Square
prototype could look like this.
var Square = { construct: function(character, tableCell) { this.background = "empty"; if (character == "#") this.background = "wall"; else if (character == "*") this.background = "exit"; this.tableCell = tableCell; this.tableCell.className = this.background; this.content = null; if (character == "0") this.content = "boulder"; else if (character == "@") this.content = "player"; if (this.content != null) { var image = dom("IMG", {src: "img/sokoban/" + this.content + ".gif"}); this.tableCell.appendChild(image); } }, hasPlayer: function() { return this.content == "player"; }, hasBoulder: function() { return this.content == "boulder"; }, isEmpty: function() { return this.content == null && this.background == "empty"; }, isExit: function() { return this.background == "exit"; } }; var testSquare = Square.create("@", dom("TD")); show(testSquare.hasPlayer());
¶ The character
argument to the constructor will be used to transform
characters from the level blueprints into actual Square
objects. To
set the background of the cells, style-sheet classes are used (defined
in sokoban.css), which are assigned to the td
elements' className
property.
¶ The methods like hasPlayer
and isEmpty
are a way to 'isolate' the
code that uses objects of this type from the internals of the objects.
They are not strictly necessary in this case, but they will make the
other code look better.
¶ Add methods moveContent
and clearContent
to the Square
prototype. The first one takes another Square
object as an argument,
and moves the content of the this
square into the argument by
updating the content
properties and moving the image node associated
with this content. This will be used to move boulders and players
around the grid. It may assume the square is not currently empty.
clearContent
removes the content from the square without moving it
anywhere. Note that the content
property for empty squares contains
null
.
¶ The removeElement
function we defined in chapter 12 is available in this
chapter too, for your node-removing convenience. You may assume that
the images are the only child nodes of the table cells, and can thus
be reached through, for example, this.tableCell.lastChild
.
Square.moveContent = function(target) { target.content = this.content; this.content = null; target.tableCell.appendChild(this.tableCell.lastChild); }; Square.clearContent = function() { this.content = null; removeElement(this.tableCell.lastChild); };
¶ The next object type will be called SokobanField
. Its constructor is
given an object from the sokobanLevels
array, and is responsible for
building both a table of DOM nodes and a grid of Square
objects.
This object will also take care of the details of moving the player
and boulders around, through a move
method that is given an argument
indicating which way the player wants to move.
¶ To identify the individual squares, and to indicate directions, we
will again use the Point
object type from chapter 8, which, as you might
remember, has an add
method.
¶ The base of the field prototype looks like this:
var SokobanField = { construct: function(level) { var tbody = dom("TBODY"); this.squares = []; this.bouldersToGo = level.boulders; for (var y = 0; y < level.field.length; y++) { var line = level.field[y]; var tableRow = dom("TR"); var squareRow = []; for (var x = 0; x < line.length; x++) { var tableCell = dom("TD"); tableRow.appendChild(tableCell); var square = Square.create(line.charAt(x), tableCell); squareRow.push(square); if (square.hasPlayer()) this.playerPos = new Point(x, y); } tbody.appendChild(tableRow); this.squares.push(squareRow); } this.table = dom("TABLE", {"class": "sokoban"}, tbody); this.score = dom("DIV", null, "..."); this.updateScore(); }, getSquare: function(position) { return this.squares[position.y][position.x]; }, updateScore: function() { this.score.firstChild.nodeValue = this.bouldersToGo + " boulders to go."; }, won: function() { return this.bouldersToGo <= 0; } }; var testField = SokobanField.create(sokobanLevels[0]); show(testField.getSquare(new Point(10, 2)).content);
¶ The constructor goes over the lines and characters in the level, and
stores the Square
objects in the squares
property. When it
encounters the square with the player, it saves this position as
playerPos
, so that we can easily find the square with the player
later on. getSquare
is used to find a Square
object corresponding
to a certain x,y
position on the field. Note that it doesn't take
the edges of the field into account ― to avoid writing some boring
code, we assume that the field is properly walled off, making it
impossible to walk out of it.
¶ The word "class"
in the dom
call that makes the table
node is
quoted as a string. This is necessary because class
is a 'reserved
word' in JavaScript, and may not be used as a variable or property
name.
¶ The amount of boulders that have to be cleared to win the level (this
may be less than the total amount of boulders on the level) is stored
in bouldersToGo
. Whenever a boulder is brought to the exit, we can
subtract 1 from this, and see whether the game is won yet. To show the
player how he is doing, we will have to show this amount somehow. For
this purpose, a div
element with text is used. div
nodes are
containers without inherent markup. The score text can be updated with
the updateScore
method. The won
method will be used by the
controller object to determine when the game is over, so the player
can move on to the next level.
¶ If we want to actually see the playing field and the score, we will
have to insert them into the document somehow. That is what the
place
method is for. We'll also add a remove
method to make it
easy to remove a field when we are done with it.
SokobanField.place = function(where) { where.appendChild(this.score); where.appendChild(this.table); }; SokobanField.remove = function() { removeElement(this.score); removeElement(this.table); }; testField.place(document.body);
¶ If all went well, you should see a Sokoban field now.
¶ But this field doesn't do very much yet. Add a method called move
.
It takes a Point
object specifying the move as argument (for example
-1,0
to move left), and takes care of moving the game elements in
the correct way.
¶ The correct way is this: The playerPos
property can be used to
determine where the player is trying to move. If there is a boulder
here, look at the square behind this boulder. When there is an exit
there, remove the boulder and update the score. When there is empty
space there, move the boulder into it. Next, try to move the player.
If the square he is trying to move into is not empty, ignore the move.
SokobanField.move = function(direction) { var playerSquare = this.getSquare(this.playerPos); var targetPos = this.playerPos.add(direction); var targetSquare = this.getSquare(targetPos); // Possibly pushing a boulder if (targetSquare.hasBoulder()) { var pushTarget = this.getSquare(targetPos.add(direction)); if (pushTarget.isEmpty()) { targetSquare.moveContent(pushTarget); } else if (pushTarget.isExit()) { targetSquare.moveContent(pushTarget); pushTarget.clearContent(); this.bouldersToGo--; this.updateScore(); } } // Moving the player if (targetSquare.isEmpty()) { playerSquare.moveContent(targetSquare); this.playerPos = targetPos; } };
¶ By taking care of boulders first, the move code can work the same way
when the player is moving normally and when he is pushing a boulder.
Note how the square behind the boulder is found by adding the
direction
to the playerPos
twice. Test it by moving left two
squares:
testField.move(new Point(-1, 0)); testField.move(new Point(-1, 0));
¶ If that worked, we moved a boulder into a place from which we can't get it out anymore, so we'd better throw this field away.
testField.remove();
¶ All the 'game logic' has been taken care of now, and we just need a
controller to make it playable. The controller will be an object type
called SokobanGame
, which is responsible for the following things:
- Preparing a place where the game field can be placed.
- Building and removing
SokobanField
objects. - Capturing key events and calling the
move
method on current field with the correct argument. - Keeping track of the current level number and moving to the next level when a level is won.
- Adding buttons to reset the current level or the whole game (back to level 0).
¶ We start again with an unfinished prototype.
var SokobanGame = { construct: function(place) { this.level = null; this.field = null; var newGame = dom("BUTTON", null, "New game"); addHandler(newGame, "click", method(this, "newGame")); var reset = dom("BUTTON", null, "Reset level"); addHandler(reset, "click", method(this, "reset")); this.container = dom("DIV", null, dom("H1", null, "Sokoban"), dom("DIV", null, newGame, " ", reset)); place.appendChild(this.container); addHandler(document, "keydown", method(this, "keyDown")); this.newGame(); }, newGame: function() { this.level = 0; this.reset(); }, reset: function() { if (this.field) this.field.remove(); this.field = SokobanField.create(sokobanLevels[this.level]); this.field.place(this.container); }, keyDown: function(event) { // To be filled in } };
¶ The constructor builds a div
element to hold the field, along with
two buttons and a title. Note how method
is used to attach methods
on the this
object to events.
¶ We can put a Sokoban game into our document like this:
var sokoban = SokobanGame.create(document.body);
¶ All that is left to do now is filling in the key event handler.
Replace the keyDown
method of the prototype with one that detects
presses of the arrow keys and, when it finds them, moves the player in
the correct direction. The following Dictionary
will probably come
in handy:
var arrowKeyCodes = new Dictionary({ 37: new Point(-1, 0), // left 38: new Point(0, -1), // up 39: new Point(1, 0), // right 40: new Point(0, 1) // down });
¶ After an arrow key has been handled, check this.field.won()
to find
out if that was the winning move. If the player won, use alert
to
show a message, and go to the next level. If there is no next level
(check sokobanLevels.length
), restart the game instead.
¶ It is probably wise to stop the events for key presses after handling them, otherwise pressing arrow-up and arrow-down will scroll your window, which is rather annoying.
SokobanGame.keyDown = function(event) { if (arrowKeyCodes.contains(event.keyCode)) { event.stop(); this.field.move(arrowKeyCodes.lookup(event.keyCode)); if (this.field.won()) { if (this.level < sokobanLevels.length - 1) { alert("Excellent! Going to the next level."); this.level++; this.reset(); } else { alert("You win! Game over."); this.newGame(); } } } };
¶ It has to be noted that capturing keys like this ― adding a handler
to the document
and stopping the events that you are looking for ―
is not very nice when there are other elements in the document. For
example, try moving the cursor around in the text field at the top of
the document. ― It won't work, you'll only move the little man in the
Sokoban game. If a game like this were to be used in a real site, it
is probably best to put it in a frame or window of its own, so that it
only grabs events aimed at its own window.
¶ When brought to the exit, the boulders vanish rather abruptly. By
modifying the Square.clearContent
method, try to show a 'falling'
animation for boulders that are about to be removed. Make them grow
smaller for a moment before, and then disappear. You can use
style.width = "50%"
, and similarly for style.height
, to make an
image appear, for example, half as big as it usually is.
¶ We can use setInterval
to handle the timing of the animation. Note
that the method makes sure to clear the interval after it is done. If
you don't do that, it will continue wasting your computer's time until
the page is closed.
Square.clearContent = function() { self.content = null; var image = this.tableCell.lastChild; var size = 100; var animate = setInterval(function() { size -= 10; image.style.width = size + "%"; image.style.height = size + "%"; if (size < 60) { clearInterval(animate); removeElement(image); } }, 70); };
¶ Now, if you have a few hours to waste, try finishing all levels.
¶ Other event types that can be useful are
focus
and blur
, which are fired on elements that can be
'focused', such as form inputs. focus
, obviously, happens when you
put the focus on the element, for example by clicking on it. blur
is
JavaScript-speak for 'unfocus', and is fired when the focus leaves the
element.
addHandler($("textfield"), "focus", function(event) { event.target.style.backgroundColor = "yellow"; }); addHandler($("textfield"), "blur", function(event) { event.target.style.backgroundColor = ""; });
¶ Another event related to form inputs is change
. This
is fired when the content of the input has changed... except that for
some inputs, such as text inputs, it does not fire until the element
is unfocused.
addHandler($("textfield"), "change", function(event) { print("Content of text field changed to '", event.target.value, "'."); });
¶ You can type all you want, the event will only fire when you click outside of the input, press tab, or unfocus it in some other way.
¶ Forms also have a submit
event, which is fired when
they submit. It can be stopped to prevent the submit from taking
place. This gives us a much better way to do the form validation we
saw in the previous chapter. You just register a submit
handler,
which stops the event when the content of the form is not valid. That
way, when the user does not have JavaScript enabled, the form will
still work, it just won't have instant validation.
¶ Window objects have a load
event that fires
when the document is fully loaded, which can be useful if your script
needs to do some kind of initialisation that has to wait until the
whole document is present. For example, the scripts on the pages for
this book go over the current chapter to hide solutions to exercises.
You can't do that when the exercises are not loaded yet. There is also
an unload
event, firing when the user leaves the document, but
this is not properly supported by all browsers.
¶ Most of the time it is best to leave the laying out of a
document to the browser, but there are effects that can only be
produced by having a piece of JavaScript set the exact sizes of some
nodes in a document. When you do this, make sure you also listen for
resize
events on the window, and re-calculate the sizes of your
element every time the window is resized.
¶ Finally, I have to tell you something about event handlers that you would rather not know. The Internet Explorer browser (which means, at the time of writing, the browser used by a majority of web-surfers) has a bug that causes values to not be cleaned up as normal: Even when they are no longer used, they stay in the machine's memory. This is known as a memory leak, and, once enough memory has been leaked, will seriously slow down a computer.
¶ When does this leaking occur? Due to a deficiency in Internet Explorer's garbage collector, the system whose purpose it is to reclaim unused values, when you have a DOM node that, through one of its properties or in a more indirect way, refers to a normal JavaScript object, and this object, in turn, refers back to that DOM node, both objects will not be collected. This has something to do with the fact that DOM nodes and other JavaScript objects are collected by different systems ― the system that cleans up DOM nodes will take care to leave any nodes that are still referenced by JavaScript objects, and vice versa for the system that collects normal JavaScript values.
¶ As the above description shows, the problem is not specifically related to event handlers. This code, for example, creates a bit of un-collectable memory:
var jsObject = {link: document.body}; document.body.linkBack = jsObject;
¶ Even after such an Internet Explorer browser goes to a different page,
it will still hold on to the document.body
shown here. The reason
this bug is often associated with event handlers is that it is
extremely easy to make such circular links when registering a handler.
The DOM node keeps references to its handlers, and the handler, most
of the time, has a reference to the DOM node. Even when this reference
is not intentionally made, JavaScript's scoping rules tend to add it
implicitly. Consider this function:
function addAlerter(element) { addHandler(element, "click", function() { alert("Alert! ALERT!"); }); }
¶ The anonymous function that is created by the addAlerter
function
can 'see' the element
variable. It doesn't use it, but that does not
matter ― just because it can see it, it will have a reference to it.
By registering this function as an event handler on that same
element
object, we have created a circle.
¶ There are three ways to deal with this problem. The first approach, a very popular one, is to ignore it. Most scripts will only leak a little bit, so it takes a long time and a lot of pages before the problems become noticeable. And, when the problems are so subtle, who's going to hold you responsible? Programmers given to this approach will often searingly denounce Microsoft for their shoddy programming, and state that the problem is not their fault, so they shouldn't be fixing it.
¶ Such reasoning is not entirely without merit, of course. But when half your users are having problems with the web-pages you make, it is hard to deny that there is a practical problem. Which is why people working on 'serious' sites usually make an attempt not to leak any memory. Which brings us to the second approach: Painstakingly making sure that no circular references between DOM objects and regular objects are created. This means, for example, rewriting the above handler like this:
function addAlerter(element) { addHandler(element, "click", function() { alert("Alert! ALERT!"); }); element = null; }
¶ Now the element
variable no longer points at the DOM node, and the
handler will not leak. This approach is viable, but requires the
programmer to really pay attention.
¶ The third solution, finally, is to not worry too much about creating
leaky structures, but to make sure to clean them up when you are done
with them. This means unregistering any event handlers when they are
no longer needed, and registering an onunload
event to unregister
the handlers that are needed until the page is unloaded. It is
possible to extend an event-registering system, like our addHandler
function, to automatically do this. When taking this approach, you
must keep in mind that event handlers are not the only possible source
of memory leaks ― adding properties to DOM node objects can cause
similar problems.