this repo has no description
at main 247 lines 10 kB view raw
1(** Widget renderer for x-ocaml. 2 3 Renders view node JSON (from js_top_worker widget protocol) into real DOM 4 elements using Brr, and wires event handlers back to the worker. 5 6 Supports two kinds of widgets: 7 - Element/Text views: declarative DOM trees, fully replaced on each update 8 - Managed widgets: delegate to registered adapters (e.g. Leaflet maps) that 9 manage their own DOM and respond to config updates and commands *) 10 11open Brr 12 13(** Type alias for the function that sends widget events back to the worker. *) 14type send_fn = 15 widget_id:string -> handler_id:string -> 16 event_type:string -> value:string option -> unit 17 18(** A managed widget adapter. Registered client-side per [kind]. 19 All functions receive and return raw Jv.t values (JS objects). *) 20type adapter = { 21 create : Jv.t -> string -> send_fn -> Jv.t; 22 (** [create container config send] creates the widget and returns adapter state *) 23 update : Jv.t -> string -> unit; 24 (** [update state config] reconciles a config change *) 25 command : Jv.t -> string -> string -> unit; 26 (** [command state cmd data] handles an imperative command *) 27 destroy : Jv.t -> unit; 28 (** [destroy state] cleans up *) 29} 30 31(** Global adapter registry: kind -> adapter *) 32let adapters : (string, adapter) Hashtbl.t = Hashtbl.create 8 33 34(** Register an adapter for the given [kind] string. *) 35let register_adapter kind adapter = 36 Hashtbl.replace adapters kind adapter 37 38(** Register an adapter from JavaScript code. 39 The JS must be an IIFE returning [{create, update, command, destroy}]. 40 [send] in JS is [send(handler_id, value_string)]. *) 41let register_js_adapter ~(send : send_fn) kind js_code = 42 let obj = Jv.call Jv.global "eval" [| Jv.of_string js_code |] in 43 let adapter = { 44 create = (fun container_jv config_str send_fn -> 45 let js_send = Jv.repr (fun handler_id value -> 46 let hid = Jv.to_string handler_id in 47 let v = 48 if Jv.is_null value || Jv.is_undefined value then None 49 else Some (Jv.to_string value) 50 in 51 send_fn ~widget_id:"" ~handler_id:hid ~event_type:hid ~value:v 52 ) in 53 Jv.call obj "create" [| container_jv; Jv.of_string config_str; js_send |]); 54 update = (fun state config_str -> 55 Jv.call obj "update" [| state; Jv.of_string config_str |] |> ignore); 56 command = (fun state cmd data -> 57 Jv.call obj "command" [| state; Jv.of_string cmd; Jv.of_string data |] |> ignore); 58 destroy = (fun state -> 59 Jv.call obj "destroy" [| state |] |> ignore); 60 } in 61 ignore send; (* send is captured by the adapter's create wrapper at call time *) 62 Hashtbl.replace adapters kind adapter 63 64(** Per-widget state *) 65type widget_entry = { 66 container : El.t; 67 widget_id : string; 68 managed : (string * Jv.t) option; 69 (** For managed widgets: (kind, adapter_state) *) 70} 71 72(** Global registry of active widgets *) 73let widgets : (string, widget_entry) Hashtbl.t = Hashtbl.create 16 74 75(** The current anchor element — new widget containers are inserted after this. 76 Set by [set_active_cell] before each cell eval begins. *) 77let active_cell : Jv.t option ref = ref None 78 79(** Set the currently active cell element. Call this before each eval so that 80 any widgets created during that eval are placed right after the cell. *) 81let set_active_cell (el : Jv.t) = active_cell := Some el 82 83(** Recursively render a view node JSON object to a DOM element. 84 [send] is called when an event handler fires. *) 85let rec render_node ~widget_id ~(send : send_fn) (node : Jv.t) : El.t = 86 let t = Jv.to_string (Jv.get node "t") in 87 match t with 88 | "txt" -> 89 let v = Jv.to_string (Jv.get node "v") in 90 El.span [ El.txt' v ] 91 | "el" -> 92 let tag = Jv.to_string (Jv.get node "tag") in 93 let attrs_arr = 94 let a = Jv.get node "a" in 95 if Jv.is_none a || Jv.is_undefined a then [||] 96 else Jv.to_jv_array a 97 in 98 let children_arr = 99 let c = Jv.get node "c" in 100 if Jv.is_none c || Jv.is_undefined c then [||] 101 else Jv.to_jv_array c 102 in 103 let el = El.v (Jstr.v tag) [] in 104 (* Apply attributes *) 105 Array.iter (fun attr -> 106 let at = Jv.to_string (Jv.get attr "t") in 107 match at with 108 | "prop" -> 109 let k = Jv.to_string (Jv.get attr "k") in 110 let v = Jv.to_string (Jv.get attr "v") in 111 El.set_at (Jstr.v k) (Some (Jstr.v v)) el 112 | "style" -> 113 let k = Jv.to_string (Jv.get attr "k") in 114 let v = Jv.to_string (Jv.get attr "v") in 115 El.set_inline_style (Jstr.v k) (Jstr.v v) el 116 | "cls" -> 117 let v = Jv.to_string (Jv.get attr "v") in 118 El.set_class (Jstr.v v) true el 119 | "handler" -> 120 let ev_name = Jv.to_string (Jv.get attr "ev") in 121 let handler_id = Jv.to_string (Jv.get attr "id") in 122 let ev_type = Ev.Type.create (Jstr.v ev_name) in 123 let _listener = Ev.listen ev_type (fun _ev -> 124 let is_input = 125 let tn = String.lowercase_ascii (Jstr.to_string (El.tag_name el)) in 126 tn = "input" || tn = "select" || tn = "textarea" 127 in 128 let value = 129 if is_input then 130 Some (Jv.to_string (Jv.get (El.to_jv el) "value")) 131 else None 132 in 133 send ~widget_id ~handler_id ~event_type:ev_name ~value 134 ) (El.as_target el) in 135 () 136 | _ -> () 137 ) attrs_arr; 138 (* Append children *) 139 Array.iter (fun child -> 140 let child_el = render_node ~widget_id ~send child in 141 El.append_children el [ child_el ] 142 ) children_arr; 143 el 144 | _ -> 145 El.span [] 146 147(** Find or create a widget container. New containers are inserted right after 148 the currently active x-ocaml cell element, so widgets appear inline with 149 their code. On subsequent updates the existing container is reused in place. *) 150let find_or_create_container widget_id = 151 match Hashtbl.find_opt widgets widget_id with 152 | Some entry -> entry.container 153 | None -> 154 let container = El.div ~at:[ At.class' (Jstr.v "widget-container") ] [] in 155 El.set_at (Jstr.v "data-widget-id") (Some (Jstr.v widget_id)) container; 156 (* Insert after the active cell element, or fall back to document.body *) 157 (match !active_cell with 158 | Some cell_jv -> 159 (* Walk past any existing widget-containers that are already siblings 160 right after this cell, so multiple widgets from the same cell 161 stack in creation order. *) 162 let next_sibling = ref (Jv.get cell_jv "nextElementSibling") in 163 let insert_after = ref cell_jv in 164 while not (Jv.is_null !next_sibling || Jv.is_undefined !next_sibling) && 165 (let cls = Jv.to_jstr (Jv.get !next_sibling "className") in 166 Jstr.equal cls (Jstr.v "widget-container")) do 167 insert_after := !next_sibling; 168 next_sibling := Jv.get !next_sibling "nextElementSibling" 169 done; 170 Jv.call !insert_after "insertAdjacentElement" 171 [| Jv.of_string "afterend"; El.to_jv container |] |> ignore 172 | None -> 173 (* No active cell — fall back to document.body *) 174 let body = El.to_jv (Document.body G.document) in 175 Jv.call body "appendChild" [| El.to_jv container |] |> ignore); 176 let entry = { container; widget_id; managed = None } in 177 Hashtbl.replace widgets widget_id entry; 178 container 179 180(** Update (or create) a widget with a new view. *) 181let update_widget ~(send : send_fn) widget_id (view_json : Jv.t) = 182 let t = Jv.to_string (Jv.get view_json "t") in 183 if t = "managed" then begin 184 let kind = Jv.to_string (Jv.get view_json "kind") in 185 let config = Jv.to_string (Jv.get view_json "config") in 186 match Hashtbl.find_opt widgets widget_id with 187 | Some entry when entry.managed <> None -> 188 (* Already created — just update config *) 189 let (_k, state) = Option.get entry.managed in 190 (match Hashtbl.find_opt adapters kind with 191 | Some adapter -> adapter.update state config 192 | None -> ()) 193 | _ -> 194 (* First render — create via adapter *) 195 let container = find_or_create_container widget_id in 196 (match Hashtbl.find_opt adapters kind with 197 | None -> 198 (* No adapter registered — render an error message *) 199 El.set_children container 200 [El.span [El.txt' (Printf.sprintf "No adapter for '%s'" kind)]] 201 | Some adapter -> 202 (* Wrap send so the adapter doesn't need to know its widget_id *) 203 let wrapped_send ~widget_id:_ ~handler_id ~event_type ~value = 204 send ~widget_id ~handler_id ~event_type ~value 205 in 206 let state = adapter.create (El.to_jv container) config wrapped_send in 207 let entry = { container; widget_id; managed = Some (kind, state) } in 208 Hashtbl.replace widgets widget_id entry) 209 end else begin 210 (* Existing Element/Text path — full DOM replacement *) 211 let container = find_or_create_container widget_id in 212 El.set_children container []; 213 let dom = render_node ~widget_id ~send view_json in 214 El.append_children container [ dom ] 215 end 216 217(** Update config for a managed widget. *) 218let config_widget widget_id config = 219 match Hashtbl.find_opt widgets widget_id with 220 | Some { managed = Some (kind, state); _ } -> 221 (match Hashtbl.find_opt adapters kind with 222 | Some adapter -> adapter.update state config 223 | None -> ()) 224 | _ -> () 225 226(** Send a command to a managed widget. *) 227let command_widget widget_id cmd data = 228 match Hashtbl.find_opt widgets widget_id with 229 | Some { managed = Some (kind, state); _ } -> 230 (match Hashtbl.find_opt adapters kind with 231 | Some adapter -> adapter.command state cmd data 232 | None -> ()) 233 | _ -> () 234 235(** Remove a widget and its container. Calls adapter destroy for managed widgets. *) 236let clear_widget widget_id = 237 match Hashtbl.find_opt widgets widget_id with 238 | Some entry -> 239 (match entry.managed with 240 | Some (kind, state) -> 241 (match Hashtbl.find_opt adapters kind with 242 | Some adapter -> adapter.destroy state 243 | None -> ()) 244 | None -> ()); 245 El.remove entry.container; 246 Hashtbl.remove widgets widget_id 247 | None -> ()