Comet paso a paso: pizarra colaborativa para páginas web
Hoy vamos a ver otro ejemplo de Comet, que a los que trabajamos en desarrollo web, nos puede venir muy bien, para cuando queremos testear una aplicación web de forma conjunta y online. También puede ser útil para realizar presentaciones.
Se trata de una pizarra colaborativa, para dibujar sobre páginas web. Cada usuario dibuja sobre el navegador y el resto de los usuarios ven lo que ha dibujado. Nada mejor para entenderlo que el siguiente video.
Se van a usar sobre todo tres cosas: comet, canvas y http://www.php.net/xml, todo sobre Firefox.
El proceso que usamos para poder crear este ejemplo es el siguiente: tenemos un iframe que contiene la página que vamos a ver, una capa que hará de pantalla (detecta el pintar sobre la página) y una capa que canvas que será donde se dibuja. Para entederlo en más detalle, vamos a ver como serÃa paso a paso:
- Cargamos la página en el iframe.
- Dimensionar el iframe, la capa “pantalla” y el canvas al tamaño de la página que vamos a mostrar.
- Empezar a recibir datos del servidor para ver que otras personas han dibujado (guardado en un XML llamado comandos.xml).
- Capturar el evento onmousemove para la capa “pantalla” para que cuando se halla pulsado el ratón (onmousedown), se ponga a dibujar.
- Cuando se haya soltado el ratón (onmouseup) se dibuja el path en el canvas y se envÃan todos los comandos que se han ejecutado en el servidor.
En la parte del servidor disponemos de dos scripts, uno para recibir los comandos que hemos ejecutado (mandar.php), y otro que recibiendo el usuario, y una marca de tiempo, enviamos que comandos de otros usuarios no ha recibido aún (enviar.php).
- El script que recibe los comandos simplemente se encarga de añadir un elemento al XML comandos.xml al final del XML con la marca de tiempo actual.
- El script que envia los comandos que aún no se han ejecutado, para ello recorre todos los elementos, y comprueba si el usuario es distinto y si la marca de tiempo es posterior a la que enviamos (que será la más antigua que ha recibido del script anteriormente).
Empecemos a mirar el código, primeramente la parte cliente, que peude ser la más compleja.
El HTML necesario para crear las capas que antes hemos indicado es el siguiente.
<div class="pagina">
<iframe id="pagina" src="sentidoweb.com.html" width="520" height="520"></iframe>
</div>
<div class="canvas" id="pantalla"
onmousedown="lapiz(event, true)"
onmouseup="lapiz(event, false)"
onmousemove="pinta(event)">
<canvas id="canvas" width="500" height="500"></canvas>
</div>
Junto sus estilos, que simplemente los posiciona de forma absoluta para que se posicionen unos encima de otros.
div.pagina {
position: absolute;
}
div.canvas {
position: absolute;
z-index: 100;
background: url(sp.gif);
}
canvas {
overflow: auto;
border: 2px solid rgb(0, 133, 133);
}
Podrás ver que en el div.canvas usa un background que es un gif transparente totalmente, esto es para que cuando se pulse en esta capa, sea en esta capa únicamente y no acceda a las que está debajo de ella.
El código Javascript es el siguiente, empezando por la declaración de variables globales.
var ctx = null; // Contexto canvas
var estadoLapiz = false; // true = esta dibujando; false = no está dibujando
// Posicion X e Y del punto anterior
// para que haya demasiados puntos juntos
var antX = -10;
var antY = -10;
var espacio = 5; // espacion mÃnimo entre puntos
var cargado = false; // Si la página se ha cargado
// Alto y ancho de la página que se carga
var altoPag = 0;
var anchoPag = 0;
// El grupo de comandos para canvas que se ejecutan y luego se envian
var comando = "";
// Id del usuario
var usuario = (Math.random()*(new Date()).getTime())+"";
// Marca de tiempo para saber que se ha recibido
var tiempo = 0;
Inicialmente, cuando se cargue la página, vamos a redimensionar las capas y a empezar a leer los datos.
// Inicialización
function init() {
var canvas = document.getElementById("canvas");
var pagina = document.getElementById("pagina");
var pantalla = document.getElementById("pantalla");
// Obtenemos el contexto del canvas
ctx = canvas.getContext('2d');
// Estilos por defecto
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.strokeStyle = "rgba(200, 0, 0, 0.5)";
var pagina = document.getElementById("pagina");
cargado = true;
// Se cambia el tamaño del iframe para que ocupe lo mismo que la página
pagina.style.width = document.body.offsetWidth+"px";
altoPag = document.getElementById("pagina").contentDocument.body.offsetHeight;
anchoPag = document.getElementById("pagina").contentDocument.body.offsetWidth;
// Se cambia el tamaño del canvas para que ocupe toda la página
canvas.width = anchoPag;
canvas.height = altoPag;
// Se cambia el tamaño de la "pantalla" que detecta los movimientos del ratón
pagina.style.height = altoPag+"px";
pantalla.style.width = anchoPag+"px";
pantalla.style.height = altoPag+"px";
// Se empieza con la lectura de lo que ha dibujado otro usuario
lectura();
}
Para dibujar sobre el canvas, vamos a capturar tres eventos en la capa “pantalla”: onmousemove para crear el path (función pinta), onmousedown para saber que empezamos a dibujar (función lapiz) y onmouseup para saber que dejamos de dibujar (función lapiz).
// Acciones que se realizan cuando se pincha y suelta el ratón
function lapiz(evt, ok) {
// Si se ha cargado la imagen
if (cargado) {
estadoLapiz = ok;
// Si se pincha (mouse down)
if (ok) {
// Se almancena el primer punto
antX = evt.layerX;
antY = evt.layerY;
// Si inicializa el grupo de comandos que se van a enviar
comando = "";
// Estilosp or defecto
ctx.lineWidth = 4;
ctx.strokeStyle = "rgba(200, 0, 0, 0.5)";
// Empezamos el path
ctx.beginPath();
// Nos movemos a la posicion inicial
ctx.moveTo(antX, antY);
// Almacenamos los comandos ejecutados para luego enviarlos
comando += "ctx.beginPath();\n";
comando += "ctx.moveTo("+antX+", "+antY+");\n";
// Si se suelta el ratón (mouse up)
} else {
// Se dibuja el path
ctx.lineWidth = 3;
ctx.stroke();
// Se manda lo que hemos dibujado
comando += "ctx.stroke();\n";
mandar();
// Se inicializa el comando
comando = "";
}
}
}
// Crea el path en el canvas según se mueve el ratón
function pinta(evt) {
// Si se ha pulsado el ratón (mouse down)
if (estadoLapiz) {
// Almacenamos el punto
var x = evt.layerX;
var y = evt.layerY;
// Si el punto actual difiere en 3 pixels del punto anterior entonces dibujamos
if (Math.abs(x-antX) > espacio || Math.abs(y-antY) > espacio) {
// Creo la lÃnea
ctx.lineTo(x, y);
// Almaceno el comando
comando += "ctx.lineTo("+x+", "+y+");\n";
antX = x;
antY = y;
}
}
}
Las funciones que se encargan de la comunicación entre el cliente y el servidor (comet) son las siguientes: la creación del objeto, la que lee datos cada cierto tiempo, enviar datos, recibir datos.
// Creacion del objeto Comet
function cometobj() {
try {
_cometobj = new ActiveXObject("Msxml2.XMLHTTP");
} catch (e) {
try {
_cometobj = new ActiveXObject("Microsoft.XMLHTTP");
} catch (E) {
_cometobj = false;
}
}
if (!_cometobj && typeof XMLHttpRequest!='undefined') {
_cometobj = new XMLHttpRequest();
}
return _cometobj;
}
// Inicialización
function init() {
var canvas = document.getElementById("canvas");
var pagina = document.getElementById("pagina");
var pantalla = document.getElementById("pantalla");
// Obtenemos el contexto del canvas
ctx = canvas.getContext('2d');
// Estilos por defecto
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.strokeStyle = "rgba(200, 0, 0, 0.5)";
var pagina = document.getElementById("pagina");
cargado = true;
// Se cambia el tamaño del iframe para que ocupe lo mismo que la página
pagina.style.width = document.body.offsetWidth+"px";
altoPag = document.getElementById("pagina").contentDocument.body.offsetHeight;
anchoPag = document.getElementById("pagina").contentDocument.body.offsetWidth;
// Se cambia el tamaño del canvas para que ocupe toda la página
canvas.width = anchoPag;
canvas.height = altoPag;
// Se cambia el tamaño de la "pantalla" que detecta los movimientos del ratón
pagina.style.height = altoPag+"px";
pantalla.style.width = anchoPag+"px";
pantalla.style.height = altoPag+"px";
// Se empieza con la lectura de lo que ha dibujado otro usuario
lectura();
}
// Lee lo que ha dibujado otro usuario cada cierto tiempo
function lectura() {
setTimeout("leer()", 1);
setTimeout("lectura()", 5000);
}
// Envio al servidor lo que se ha dibujado
function mandar() {
// Creamos el objeto comet
var comet = cometobj();
comet.open("POST", "mandar.php", true);
// No se espera ninguna respuesta
comet.onreadystatechange=function() {
}
// Enviamos el comando
comet.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
comet.send("&comando=" + escape(comando) + "&usuario=" + usuario);
}
// Acciones que se realizan cuando se pincha y suelta el ratón
function lapiz(evt, ok) {
// Si se ha cargado la imagen
if (cargado) {
estadoLapiz = ok;
// Si se pincha (mouse down)
if (ok) {
// Se almancena el primer punto
antX = evt.layerX;
antY = evt.layerY;
// Si inicializa el grupo de comandos que se van a enviar
comando = "";
// Estilosp or defecto
ctx.lineWidth = 4;
ctx.strokeStyle = "rgba(200, 0, 0, 0.5)";
// Empezamos el path
ctx.beginPath();
// Nos movemos a la posicion inicial
ctx.moveTo(antX, antY);
// Almacenamos los comandos ejecutados para luego enviarlos
comando += "ctx.beginPath();\n";
comando += "ctx.moveTo(" + antX + ", " + antY + ");\n";
// Si se suelta el ratón (mouse up)
} else {
// Se dibuja el path
ctx.lineWidth = 3;
ctx.stroke();
// Se manda lo que hemos dibujado
comando += "ctx.stroke();\n";
mandar();
// Se inicializa el comando
comando = "";
}
}
}
La parte servidor consta de dos scripts: leer.php y mandar.php, el primero es para leer que partes del XML tenemos que enviar al cliente, y el segundo para almancenar los comandos que hemos mandado desde el cliente.
<?php
// Lee el XML y devuelve los elementos que no se hayan ejecutaddo
// Mandamos datos en varias veces al cliente
header('Content-type: multipart/x-mixed-replace;boundary="limite01234"');
echo "--limite01234\n";
$usuario = $_POST['usuario'];
$tiempo = $_POST['tiempo'];
$ok; // Si se manda o no
$maxTiempo = 0; // El timestamp mayor
$fichero = 'comandos.xml'; // Fichero de los comandos
// Trata los elementos iniciales de un XML
// comprueba si se tiene que enviar o no
function inicio($xml, $nombre, $atributos) {
global $usuario;
global $ok;
global $tiempo;
global $maxTiempo;
$ok = (($atributos["USUARIO"] != $usuario) AND ($tiempo < $atributos["TIEMPO"])) ? 1 : 0;
$maxTiempo = max($maxTiempo, $atributos["TIEMPO"]);
}
// Trata los elementos finales de un XML
// no hace nada
function fin($xml, $name) {
}
// Trata los elementos de datos de un XML
// Si se tiene que enviar, se manda, si no,
// no hace nada
function datos($xml, $datos) {
global $usuario;
global $ok;
global $tiempo;
global $maxTiempo;
if ($ok == 1) {
echo "Content-type: text/plain\n\n";
echo "//".$maxTiempo."\n";
echo "$datos\n";
echo "--limite01234\n";
flush();
}
$ok = 0;
}
// Parseo el XML
$xml = xml_parser_create();
xml_set_element_handler($xml, "inicio", "fin");
xml_set_character_data_handler($xml, "datos");
if (!($f = fopen($fichero, "r"))) {
die("No se puede abrir el fichero XML.");
}
while ($data = fread($f, 4096)) {
if (!xml_parse($xml, $data, feof($f))) {
die(sprintf("Error en el XML: %s en lÃnea %d",
xml_error_string(xml_get_error_code($xml)),
xml_get_current_line_number($xml)));
}
}
xml_parser_free($xml);
fclose($f);
// Fin del envio
echo "--limite01234--\n";
?>
<?php
$comando = $_POST['comando'];
$usuario = $_POST['usuario'];
$fichero = 'comandos.xml';
$f = null;
// Si el fichero no existe lo creo
if (!file_exists($fichero)) {
$f = fopen($fichero, 'w+');
fclose($f);
}
// Leo el fichero
$f = fopen($fichero, 'r');
$xml = "";
// Si el fichero tiene datos, elimino la etiqueta final
if (filesize($fichero) > 0) {
$xml = fread($f, filesize($fichero));
$xml = ereg_replace("\<\/comandos\>", "", $xml);
// Si el fichero no tiene datos, creo la etiqueta inicial
} else {
$xml .= '<comandos>';
}
// Añado una etiqueta con el usuario, la marca de tiempo y los datos
$xml .= '<comando usuario="'.$usuario.'" tiempo="'.time().'">'.$comando.'</comando>';
// Añado la etiqueta final
$xml .= '</comandos>';
fclose($f);
// Reemplazo los datos en el fichero
$f = fopen($fichero, 'w');
fwrite($f, $xml);
fclose($f);
?>
¿Por qué Comet y no AJAX?, porque realmente Comet lo usamos para ir recibiendo los comandos uno a uno, cuando los podrÃamos recibir en un XML e ir ejecutándolos uno a uno. Bueno, habrÃa gente que dirÃa que hacer llamadas AJAX cada cierto tiempo para recibir datos es Comet, no sé, estas cosas las dejo para los puristas.
¿Qué más se podrÃa hacer o que fallos tiene este ejemplo?
- Uno de las cosas que más me preocupa es el uso de memoria, pon el Administrador de Tareas y fÃjate en el uso de memoria de Firefox, en el momento anterior y después de cargar este ejemplo, verás que aumenta varios megas, algo increible, sobre todo como me pasaba a mà cuando estaba creando el script que necesitaba tener dos pestañas con el ejemplo. Supongo que se debe a que reserva memoria para todo el canvas y que depende del tamaño de el elemento, en este caso excesivo.
- Supongo que se podrÃa mejorar la forma de crear los paths, hasta que no sueltas el botón del ratón no se dibuja, probé a dibujar el path por cada movimiento del ratón, pero el número de comandos que se ejecutaban era excesivo.
- Otra cosa que no controlo es el hecho de que cuando quiere empezar un nuevo testeo, el usuario verá como se dibuja lo de la vez anterior. Esto es porque no se borra el fichero comandos.xml y se deberÃa borrar a cada nuevo inicio. Vamos, que no estarÃa mal poder incorporarle algunos comandos: borrar pantalla, etc...
- No he dado la oportunidad de que se cargue cualquier página, de todas formas, no tengo muy claro de que se pueda cargar una página externa a tu servidor, porque tenemos que acceder a la página para ver el alto y ancho de esta, y puede que si la página es externa, esta información nos esté restringida.
- Control de usuarios, claro, que si hago esto es para nota, y me tendrÃa que pasar una o dos semanas con este script al 100% y no podrÃa escribir en Sentido Web.
- Dar la posibilidad de elegir el color, el ancho del pincel, ... esto para cuando salgamos de la beta.
Y poco más, si le sacáis partido a este script, por favor, decÃdnoslo y si no lo vais a usarlo nunca, espero que os de alguna idea para algo que vayáis a realizar.
Podéis bajaros el código fuente aquÃ