Miscellaneous features

The code of this tutorial has been tested with Eliom 4.

Multi-user collaborative drawing application

We now want to take back our collaborative drawing application and turn it to a multi user one. Each user will have his own drawing, where everyone can draw, and the owner will have the ability to save the drawing he wants and create an Atom feed containing all saved drawings.

See the full code of examples.

Split application into multiple files and using several canvas

In this first section, we will build a multi-canvas drawing application. Each drawing has its own URL. Everyone can create a new drawing by going to the corresponding URL.

We need to refactor some parts. In particular, we need to be able to handle separately different drawings. To do this, we will turn all global variable, like the bus, in local ones.

When an application grows, it start being useful to split it in multiple files. For sake of example, we will split graffiti into 4 files.

  • common.ml which will be part of both client and server, containing shared types and declarations
  • client.ml client only part of the application
  • server.ml server only part of the application
  • graffiti.eliom the only part where we need to include both client and server code

common.ml

It contains what was in {shared{ ... }}

type messages = ((int * int * int) * int * (int * int) * (int * int)) deriving (Json)

let width = 700
let height = 400

client.ml

It is almost the same code as what was enclosed in {client{ ... }} with the difference that what was previously in the client value function init_client is now in the function launch_client_canvas.

open Eliom_content
open Common
open Lwt

let draw ctx (color, size, (x1, y1), (x2, y2)) =
  ctx##strokeStyle <- (Js.string color);
  ctx##lineWidth <- float size;
  ctx##beginPath();
  ctx##moveTo(float x1, float y1);
  ctx##lineTo(float x2, float y2);
  ctx##stroke()

(* type containing all informations we need to stop interaction
   inside the page *)
type drawing_canceller =
    { drawing_thread : unit Lwt.t;
      (* the thread reading messages from the bus *)
      drawing_event_thread : unit Lwt.t;
      (* the thread handling mouse events *)
    }

let stop_drawing { drawing_thread; drawing_event_thread } =
  Lwt.cancel drawing_thread;
  (* cancelling this thread also closes the bus *)
  Lwt.cancel drawing_event_thread

Notice that the client does not keep data from pages it left. Lwt.cancel t stops the thread t. In this case it also closes the bus on which t is listening. For more informations see the Lwt programming guide and Eliom_bus.

let launch_client_canvas bus image_elt canvas_elt slider  =
  let canvas = Html5.To_dom.of_canvas canvas_elt in
  let ctx = canvas##getContext (Dom_html._2d_) in
  ctx##lineCap <- Js.string "round";

  let img = Html5.To_dom.of_img image_elt in
  let copy_image () = ctx##drawImage(img, 0., 0.) in
  if Js.to_bool (img##complete)
  then copy_image ()
  else img##onload <- Dom_html.handler
    (fun ev -> copy_image (); Js._false);


  (* The color palette: *)
  let colorpicker = Ojw_color_picker.create ~width:150 () in
  Ojw_color_picker.append_at (Dom_html.document##body) colorpicker;
  Ojw_color_picker.init_handler colorpicker;

  let x = ref 0 and y = ref 0 in
  let set_coord ev =
    let x0, y0 = Dom_html.elementClientPosition canvas in
    x := ev##clientX - x0; y := ev##clientY - y0 in
  let compute_line ev =
    let oldx = !x and oldy = !y in
    set_coord ev;
    let rgb = Ojw_color_picker.get_rgb colorpicker in
    let size_slider = Html5.To_dom.of_input slider in
    let size = int_of_string (Js.to_string size_slider##value) in
    (rgb, size, (oldx, oldy), (!x, !y))
  in
  let line ev =
    let v = compute_line ev in
    let _ = Eliom_bus.write bus v in
    draw ctx v;
    Lwt.return ()
  in
  let t = Lwt_stream.iter (draw ctx) (Eliom_bus.stream bus) in
  let drawing_event_thread =
    let open Lwt_js_events in
    mousedowns canvas
      (fun ev _ -> set_coord ev; line ev >>= fun () ->
        Lwt.pick [mousemoves Dom_html.document (fun x _ -> line x);
		  mouseup Dom_html.document >>= line])
  in
  { drawing_thread = t;
    drawing_event_thread = drawing_event_thread }

server.ml

It contains almost all the server parts of the code.

open Eliom_content
open Eliom_content.Html5.D
open Common

module My_app =
  Eliom_registration.App (
    struct
      let application_name = "graffiti"
    end)

The main difference is that the bus is now local.

let launch_server_canvas () =
  let bus = Eliom_bus.create Json.t<messages> in

  let draw_server, image_string =
    let rgb_ints_to_floats (r, g, b) =
      float r /. 255., float g /. 255., float b /. 255. in
    let surface = Cairo.Image.create Cairo.Image.ARGB32 ~width ~height in
    let ctx = Cairo.create surface in
    ((fun (rgb, size, (x1, y1), (x2, y2)) ->

      (* Set thickness of brush *)
      let r, g, b = rgb_ints_to_floats rgb in
      Cairo.set_line_width ctx (float size) ;
      Cairo.set_line_join ctx Cairo.JOIN_ROUND ;
      Cairo.set_line_cap ctx Cairo.ROUND ;
      Cairo.set_source_rgb ctx ~r ~g ~b ;

      Cairo.move_to ctx (float x1) (float y1) ;
      Cairo.line_to ctx (float x2) (float y2) ;
      Cairo.Path.close ctx ;

      (* Apply the ink *)
      Cairo.stroke ctx ;
     ),
    (fun () ->
       let b = Buffer.create 10000 in
       (* Output a PNG in a string *)
       Cairo_png.surface_write_to_stream surface (Buffer.add_string b);
       Buffer.contents b
     ))
  in
  let _ = Lwt_stream.iter draw_server (Eliom_bus.stream bus) in
  bus,image_string

let graffiti_info = Hashtbl.create 0

let imageservice =
  Eliom_registration.String.register_service
    ~path:["image"]
    ~headers:Http_headers.dyn_headers
    ~get_params:(let open Eliom_parameter in string "name" ** int "q")
    (* we add an int parameter for the browser not to cache the image:
       at least for chrome, there is no way to force the browser to
       reload the image without leaving the application *)
    (fun (name,_) () ->
      try_lwt
        let _ ,image_string = Hashtbl.find graffiti_info name in
	Lwt.return (image_string (), "image/png")
      with
	| Not_found -> raise_lwt Eliom_common.Eliom_404)

let get_bus (name:string) =
  (* create a new bus and image_string function only if it did not exists *)
  try
    fst (Hashtbl.find graffiti_info name)
  with
    | Not_found ->
      let bus,image_string = launch_server_canvas () in
      Hashtbl.add graffiti_info name (bus,image_string);
      bus

The main page now contains only a form to choose to which drawing you want to go. The drawing will be sent by the multigraffiti_service service, registered in graffiti.eliom.

let main_service = Eliom_service.App.service ~path:[""]
  ~get_params:(Eliom_parameter.unit) ()
let multigraffiti_service = Eliom_service.App.service ~path:[""]
  ~get_params:(Eliom_parameter.suffix (Eliom_parameter.string "name")) ()

let choose_drawing_form () =
  get_form ~service:multigraffiti_service
    (fun (name) ->
      [fieldset
          [label ~a:[a_for name]
             [pcdata "drawing name: "];
           string_input ~input_type:`Text ~name ();
           br ();
           string_input ~input_type:`Submit ~value:"Go" ()
          ]])

let oclosure_script =
  Html5.Id.create_global_elt
    (js_script
       ~uri:(make_uri  (Eliom_service.static_dir ())
               ["graffiti_oclosure.js"]) ())

let make_page content =
  Lwt.return
    (html
       (head
	  (title (pcdata "Graffiti"))
       [ css_link
           ~uri:(make_uri (Eliom_service.static_dir ())
                  ["css";"common.css"]) ();
         css_link
           ~uri:(make_uri (Eliom_service.static_dir ())
                  ["css";"hsvpalette.css"]) ();
         css_link
           ~uri:(make_uri (Eliom_service.static_dir ())
                  ["css";"slider.css"]) ();
         oclosure_script;
         css_link
           ~uri:(make_uri (Eliom_service.static_dir ())
                  ["css";"graffiti.css"]) ();
       ])
       (body content))

let () = My_app.register ~service:main_service
  (fun () () ->
    make_page [h1 [pcdata "Welcome to Multigraffiti"];
	       choose_drawing_form ()])

graffiti.eliom

Here is the code that mixes client and server parts.

We first open the corresponding modules for each parts of the application.

{shared{
  open Eliom_content.Html5.D
  open Common
}}
{client{
  open Client
}}
open Server

And then we define a function initializing the client application by side effects in a client value.

let start_drawing name image canvas slider =
  let bus = get_bus name in
  ignore {unit{
    let canceller = launch_client_canvas %bus %image %canvas in
    Eliom_client.onunload (fun () -> stop_drawing canceller)
  }}

The function registered by Eliom_service.​onunload will be called when the page change inside the application.

And we finally register the service sending a drawing:

let counter = ref 0

let () = My_app.register ~service:multigraffiti_service
  (fun name () ->
    (* Some browsers won't reload the image, so we force
       them by changing the url each time. *)
    incr counter;
    let image =
      img ~alt:name
        ~src:(make_uri
		~service:imageservice (name,!counter)) () in
    let slider = Html5.D.int_input
        ~a:[Html5.D.a_id "slider"; Html5.D.a_input_min 1.; Html5.D.a_input_max 80.]
        ~input_type:`Range () in
    let canvas =
      canvas ~a:[ a_width width; a_height height ]
        [pcdata "your browser doesn't support canvas"; br (); image] in
    start_drawing name image canvas slider;
    make_page
      [h1 [pcdata name];
       choose_drawing_form ();
       canvas;
       Html5.D.div [slider]])

At this point, you can run your application on the server provided that you installed the css and images directories in the main directory of your application, build it using this Makefile along with this Makefile.common and configured it using graffiti.conf.in as groundwork of configuration file.

Mixing client/server application with traditional web interaction

We now want to restrict the site to connected users.

From the previous chapter, we copy the code handling users to server.ml:

let connection_service =
  Eliom_service.Http.post_coservice' ~post_params:
    (let open Eliom_parameter in (string "name" ** string "password")) ()
let disconnection_service = Eliom_service.Http.post_coservice'
  ~post_params:Eliom_parameter.unit ()
let create_account_service =
  Eliom_service.Http.post_coservice ~fallback:main_service ~post_params:
  (let open Eliom_parameter in (string "name" ** string "password")) ()

let username = Eliom_reference.eref ~scope:Eliom_common.default_session_scope None

let users = ref ["user","password";"test","test"]

let check_pwd name pwd =
 try Lwt.return (List.assoc name !users = pwd) with
   | Not_found -> Lwt.return false

let () = Eliom_registration.Action.register
  ~service:create_account_service
  (fun () (name, pwd) ->
    users := (name, pwd)::!users;
    Lwt.return ())

let () = Eliom_registration.Action.register
  ~service:connection_service
  (fun () (name, password) ->
    match_lwt check_pwd name password with
      | true -> Eliom_reference.set username (Some name)
      | false -> Lwt.return ())

let () =
  Eliom_registration.Action.register
    ~service:disconnection_service
    (fun () () -> Eliom_state.discard ~scope:Eliom_common.default_session_scope ())

let disconnect_box () =
  post_form disconnection_service
    (fun _ -> [fieldset
                 [string_input ~input_type:`Submit ~value:"Log out" ()]]) ()

let login_name_form service button_text =
  post_form ~service
    (fun (name1, name2) ->
      [fieldset
         [label ~a:[a_for name1] [pcdata "login: "];
          string_input ~input_type:`Text ~name:name1 ();
          br ();
          label ~a:[a_for name2] [pcdata "password: "];
          string_input ~input_type:`Password ~name:name2 ();
          br ();
          string_input ~input_type:`Submit ~value:button_text ()
         ]]) ()

We make a customized registration module such that disconnected users (those for which the username reference is not set), are automaticaly shown a connection box. That way the other pages can assume that the username is always available.

let default_content () =
  make_page
    [h1 [pcdata "Welcome to Multigraffiti"];
     h2 [pcdata "log in"];
     login_name_form connection_service "Connect";
     h2 [pcdata "create account"];
     login_name_form create_account_service "Create account";]

module Connected_translate =
struct
  type page = string -> My_app.page Lwt.t
  let translate page =
    match_lwt Eliom_reference.get username with
      | None -> default_content ()
      | Some username -> page username
end

module Connected =
  Eliom_registration.Customize ( My_app ) ( Connected_translate )

We replace the previous main_service registration :

let () = My_app.register ~service:main_service
  (fun () () ->
    make_page [h1 [pcdata "Welcome to Multigraffiti"];
	       choose_drawing_form ()])

by :

let () = Connected.register ~service:main_service
  (fun () () ->
    Lwt.return
      (fun username ->
        make_page
          [h1 [pcdata ("Welcome to Multigraffiti " ^ username)];
          choose_drawing_form ()]))

to use that, in graffiti.eliom we just replace add a call to disconnect_box

[h1 [pcdata name];
       disconnect_box ();
       choose_drawing_form ();
       canvas;])

prev