123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662open!Coreopen!Js_of_ocamlopenVirtual_domtypeelement={tag_name:string;attributes:(string*string)list[@sexp.list];string_properties:(string*string)list[@sexp.list];bool_properties:(string*bool)list[@sexp.list];styles:(string*string)list[@sexp.list];handlers:(string*Handler.t)list[@sexp.list];hooks:(string*Vdom.Attr.Hooks.For_testing.Extra.t)list[@sexp.list];key:stringoption[@sexp.option];children:tlist[@sexp.list]}[@@derivingsexp_of]andt=|Textofstring|Elementofelement|WidgetofSexp.t[@@derivingsexp_of]letrecinner_text=function|Texts->(matchString.stripswith|""->None|s->Somes)|Element{children;_}->(matchchildren|>List.filter_map~f:inner_textwith|[]->None|xs->xs|>String.concat~sep:" "|>Some)|Widget_->None;;letinner_textt=inner_textt|>Option.value~default:""letis_tag~tag=function|Element{tag_name;_}->String.equaltag_nametag|_->false;;lethas_class~cls=function|Element{attributes;_}->List.existsattributes~f:(function|"class",data->data|>String.split~on:' '|>List.exists~f:(String.equalcls)|_->false)|_->false;;letrecmapt~f=matchftwith|`Replace_witht->t|`Continue->(matchtwith|Text_|Widget_->t|Elementelement->letchildren=List.mapelement.children~f:(funch->mapch~f)inElement{elementwithchildren});;typehidden_soup=Hidden_soup:_Soup.node->hidden_souptype'abreadcrumb_preference=|Don't_add_breadcrumbs:unitbreadcrumb_preference|Add_breadcrumbs:(Soup.elementSoup.node->t)breadcrumb_preferencemoduleSoup_id=Unique_id.Int()letsoup_id_key="soup-id"letto_lambda_soup(typea)t(breadcrumb_preference:abreadcrumb_preference):hidden_soup*a=lett_by_soup_id=String.Table.create()inletrecconvertt=matchtwith|Texts->Hidden_soup(Soup.create_texts)|Widgetw->letinfo_text=Soup.create_text(Sexp.to_stringw)inletelement=Soup.create_element"widget"~attributes:[]inSoup.append_childelementinfo_text;Hidden_soupelement|Element{tag_name;attributes(* We ignore [string_properties] / [bool_properties] as their names can overlap
with attributes. Ignoring them here currently just means that people cannot
select on them when triggering events.
*);string_properties=_;bool_properties=_;handlers;key;children;hooks;styles=_}->letkey_attrs=matchkeywith|Somekey->["key",key]|None->[]inletsoup_id_attrs=matchbreadcrumb_preferencewith|Don't_add_breadcrumbs->[]|Add_breadcrumbs->letsoup_id=Soup_id.create()|>Soup_id.to_stringinHashtbl.add_exnt_by_soup_id~key:soup_id~data:t;[soup_id_key,soup_id]inlethandler_attrs=List.maphandlers~f:(fun(name,_)->name,"<event-handler>")inlethook_attrs=List.maphooks~f:(fun(name,_)->name,"<hook>")inletattributes=[hook_attrs;key_attrs;soup_id_attrs;handler_attrs;attributes]|>List.concat|>String.Map.of_alist_exn(* Raise on duplicate attributes *)|>Map.to_alistinletelement=Soup.create_elementtag_name~attributesinList.iterchildren~f:(funchild->let(Hidden_soupchild)=convertchildinSoup.append_childelementchild);Hidden_soupelementin(convertt,matchbreadcrumb_preferencewith|Don't_add_breadcrumbs->()|Add_breadcrumbs->funsoup->(matchSoup.attributesoup_id_keysoupwith|None->raise_s[%message"Soup.node has no soup-id attribute"]|Somesoup_id->Hashtbl.find_exnt_by_soup_idsoup_id));;let_to_string_htmlt=letHidden_soupsoup,()=to_lambda_souptDon't_add_breadcrumbsinSoup.to_stringsoup;;(* Printing elements in single-line and multiline formats is essentially the
same. The main difference is what attributes are separated by: in
single-line, they are separated just by spaces, but in multiline they are
separated by a newline and some indentation.
*)letbprint_elementbuffer~sep~before_styles~filter_printed_attributes{tag_name;attributes;string_properties;bool_properties;styles;handlers;key;hooks;children=_}=bprintfbuffer"<%s"tag_name;lethas_printed_an_attribute=reffalseinletbprint_aligned_indent()=if!has_printed_an_attributethenbprintfbuffer"%s"sepelse(has_printed_an_attribute:=true;bprintfbuffer" ")inletlist_iter_filterl~f=List.filterl~f:(fun(k,_)->filter_printed_attributesk)|>List.iter~finiffilter_printed_attributes"@key"thenOption.iterkey~f:(funkey->bprint_aligned_indent();bprintfbuffer"@key=%s"key);list_iter_filterattributes~f:(fun(k,v)->bprint_aligned_indent();bprintfbuffer"%s=\"%s\""kv);list_iter_filterstring_properties~f:(fun(k,v)->bprint_aligned_indent();bprintfbuffer"#%s=\"%s\""kv);list_iter_filterbool_properties~f:(fun(k,v)->bprint_aligned_indent();bprintfbuffer"#%s=\"%b\""kv);list_iter_filterhooks~f:(fun(k,v)->bprint_aligned_indent();bprintfbuffer"%s=%s"k(v|>[%sexp_of:Vdom.Attr.Hooks.For_testing.Extra.t]|>Sexp.to_string_mach));list_iter_filterhandlers~f:(fun(k,_)->bprint_aligned_indent();bprintfbuffer"%s"k);letstyles=List.filterstyles~f:(fun(name,_)->filter_printed_attributes("style."^name))inifnot(List.is_emptystyles)then(bprint_aligned_indent();bprintfbuffer"style={";List.iterstyles~f:(fun(k,v)->bprint_aligned_indent();bprintfbuffer"%s%s: %s;"before_styleskv);bprint_aligned_indent();bprintfbuffer"}");bprintfbuffer">";;letbprint_element_single_linebufferelement=bprint_elementbuffer~sep:" "~before_styles:""element;;letbprint_element_multi_linebuffer~indentelement=letalign_with_first_attribute=String.mapelement.tag_name~f:(Fn.const' ')^" "inletsep="\n"^indent^align_with_first_attributeinbprint_elementbuffer~sep~before_styles:" "element;;letto_string_html?(filter_printed_attributes=Fn.consttrue)t=(* Keep around the buffer so that it is not re-allocated for every element *)letsingle_line_buffer=Buffer.create200inletrecrecursebuffer~depth=letindent=String.init(depth*2)~f:(Fn.const' ')infunction|Texts->bprintfbuffer"%s%s"indents|Elementelement->bprintfbuffer"%s"indent;Buffer.resetsingle_line_buffer;bprint_element_single_line~filter_printed_attributessingle_line_bufferelement;ifBuffer.lengthsingle_line_buffer<100-String.lengthindentthenBuffer.add_bufferbuffersingle_line_bufferelsebprint_element_multi_line~filter_printed_attributesbuffer~indentelement;letchildren_should_collapse=List.for_allelement.children~f:(function|Text_->true|_->false)&&List.foldelement.children~init:0~f:(funaccchild->matchchildwith|Texts->acc+String.lengths|_->acc)<80-String.lengthindentinletdepth=ifchildren_should_collapsethen0elsedepth+1inList.iterelement.children~f:(funchild->ifchildren_should_collapsethenbprintfbuffer" "elsebprintfbuffer"\n";recursebuffer~depthchild);ifchildren_should_collapsethenbprintfbuffer" "else(bprintfbuffer"\n";bprintfbuffer"%s"indent);bprintfbuffer"</%s>"element.tag_name|Widgets->bprintfbuffer"%s<widget %s />"indent(Sexp.to_strings)inletbuffer=Buffer.create100inrecursebuffer~depth:0t;Buffer.contentsbuffer;;letselectt~selector=letHidden_soupelement,find_t_by_soup_exn=to_lambda_souptAdd_breadcrumbsinletsoup=Soup.create_soup()inSoup.append_rootsoupelement;soup|>Soup.selectselector|>Soup.to_list|>List.map~f:find_t_by_soup_exn;;letselect_firstt~selector=selectt~selector|>List.hdletselect_first_exnt~selector=matchselect_firstt~selectorwith|Somenode->node|None->raise_s[%message"Failed to find element matching selector"(selector:string)~from_node:(to_string_htmlt:string)];;letunsafe_of_js_exn=letmake_text_node(text:Js.js_stringJs.t)=Text(Js.to_stringtext)inletmake_element_node(tag_name:Js.js_stringJs.t)(children:tJs.js_arrayJs.t)(handlers:(Js.js_stringJs.t*Js.Unsafe.any)Js.js_arrayJs.t)(attributes:(Js.js_stringJs.t*Js.js_stringJs.t)Js.js_arrayJs.t)(string_properties:(Js.js_stringJs.t*Js.js_stringJs.t)Js.js_arrayJs.t)(bool_properties:(Js.js_stringJs.t*boolJs.t)Js.js_arrayJs.t)(styles:(Js.js_stringJs.t*Js.js_stringJs.t)Js.js_arrayJs.t)(hooks:(Js.js_stringJs.t*Vdom.Attr.Hooks.For_testing.Extra.t)Js.js_arrayJs.t)(key:Js.js_stringJs.tJs.Opt.t)=lettag_name=tag_name|>Js.to_stringinletchildren=children|>Js.to_array|>Array.to_listinlethandlers=handlers|>Js.to_array|>Array.to_list|>List.map~f:(fun(s,h)->letname=Js.to_stringsinname,Handler.of_any_exnh~name)inletattributes=attributes|>Js.to_array|>Array.to_list|>List.map~f:(fun(k,v)->Js.to_stringk,Js.to_stringv)inlethooks=hooks|>Js.to_array|>Array.to_list|>List.map~f:(fun(k,v)->Js.to_stringk,v)inletstring_properties=string_properties|>Js.to_array|>Array.to_list|>List.map~f:(fun(k,v)->Js.to_stringk,Js.to_stringv)inletbool_properties=bool_properties|>Js.to_array|>Array.to_list|>List.map~f:(fun(k,v)->Js.to_stringk,Js.to_boolv)inletstyles=styles|>Js.to_array|>Array.to_list|>List.map~f:(fun(k,v)->Js.to_stringk,Js.to_stringv)inletkey=key|>Js.Opt.to_option|>Option.map~f:Js.to_stringinElement{tag_name;children;handlers;attributes;string_properties;bool_properties;key;hooks;styles}inletmake_widget_node(id:_Type_equal.Id.t)(info:Sexp.tLazy.toption)=matchinfowith|Somesexp->Widget(Lazy.forcesexp)|None->Widget(Sexp.Atom(Type_equal.Id.nameid))inletraise_unknown_node_typenode_type=letnode_type=Js.to_stringnode_typeinraise_s[%message"unrecognized node type"(node_type:string)]inletf=Js.Unsafe.pure_js_expr{js|
// Convert analyzes a Vdom node that was produced by [Node.to_js] and walks the tree
// recursively, calling make_text_node, make_element_node, and make_widget_node depending
// on the type of node.
(function convert(node, make_text_node, make_element_node, make_widget_node, raise_unknown_node_type) {
switch (node.type) {
case 'VirtualText':
return make_text_node(node.text);
case 'Widget':
return make_widget_node(node.id, node.info);
case 'VirtualNode':
var attributes = node.properties.attributes || {};
var attr_list = Object.keys(attributes).map(function(key) {
return [0, key, attributes[key].toString()];
});
var children = node.children.map(function(node) {
return convert(node, make_text_node, make_element_node, make_widget_node, raise_unknown_node_type);
});
var handlers =
Object.keys(node.properties)
.filter(function(key) {
// This is a bit of a hack, but it works for all the handlers that we
// have defined at the moment. Consider removing the 'on' check?
return key.startsWith("on") && typeof node.properties[key] === 'function';
})
.map(function(key) {
// [0, ...] is how to generate an OCaml tuple from the JavaScript side.
return [0, key, node.properties[key]];
});
var string_properties =
Object.keys(node.properties)
.filter(function(key) {
return typeof node.properties[key] === 'string';
})
.map(function(key) {
return [0, key, node.properties[key]]
});
var bool_properties =
Object.keys(node.properties)
.filter(function(key) {
return typeof node.properties[key] === 'boolean';
})
.map(function(key) {
return [0, key, node.properties[key]]
});
var styles =
Object.keys(node.properties.style ? node.properties.style : {})
.filter(function(key) {
return typeof node.properties.style[key] === 'string';
})
.map(function(key) {
return [0, key, node.properties.style[key]]
});
var hooks =
Object.keys(node.properties)
.filter(function(key) {
return typeof node.properties[key] === 'object' &&
typeof node.properties[key]['extra'] === 'object';
})
.map(function(key) {
return [0, key, node.properties[key]['extra']]
});
var soft_set_hooks =
Object.keys(node.properties)
.filter(function(key) {
return node.properties[key] instanceof joo_global_object.SoftSetHook;
})
.map(function(key) {
return [0, key, "" + node.properties[key].value];
});
return make_element_node(
node.tagName,
children,
handlers,
attr_list,
string_properties.concat(soft_set_hooks),
bool_properties,
styles,
hooks,
node.key || null);
default:
raise_unknown_node_type("" + node.type);
}
})
|js}infunvalue->Js.Unsafe.fun_callf[|value;Js.Unsafe.inject(Js.wrap_callbackmake_text_node);Js.Unsafe.inject(Js.wrap_callbackmake_element_node);Js.Unsafe.inject(Js.wrap_callbackmake_widget_node);Js.Unsafe.inject(Js.wrap_callbackraise_unknown_node_type)|];;letunsafe_convert_exnvdom_node=vdom_node|>Virtual_dom.Vdom.Node.to_raw|>Js.Unsafe.inject|>unsafe_of_js_exn;;letget_handlers(node:t)=matchnodewith|Element{handlers;_}->handlers|_->raise_s[%message"expected Element node"(node:t)];;lettrigger_many?extra_fieldsnode~event_names=letall_handlers=get_handlersnodeinletcount=List.countevent_names~f:(funevent_name->matchList.Assoc.findall_handlersevent_name~equal:String.equalwith|None->false|Somehandler->Handler.triggerhandler?extra_fields;true)inmatchcountwith|0->raise_s[%message"No handler found on element"(event_names:stringlist)]|_->();;lettrigger?extra_fieldsnode~event_name=trigger_many?extra_fieldsnode~event_names:[event_name];;letget_hook_value:typea.t->type_id:aType_equal.Id.t->name:string->a=funt~type_id~name->matchtwith|Element{hooks;_}->(matchList.Assoc.find~equal:String.equalhooksnamewith|Somehook->let(Vdom.Attr.Hooks.For_testing.Extra.T{type_id=type_id_v;value})=hookin(matchType_equal.Id.same_witnesstype_id_vtype_idwith|SomeT->value|None->failwithf"get_hook_value: a hook for %s was found, but the type-ids were not the same; \
are you using the same type-id that you got from the For_testing module from \
your hook creator?"name())|None->failwithf"get_hook_value: no hook found with name %s"name())|Text_->failwith"get_hook_value: expected Element, found Text"|Widget_->failwith"get_hook_value: expected Element, found Widget";;lettrigger_hookt~type_id~name~arg=Ui_effect.Expert.handle((get_hook_valuet~type_id~name)arg);;moduleUser_actions=structletprevent_default="preventDefault",Js.Unsafe.injectFn.idletstop_propagation="stopPropagation",Js.Unsafe.injectFn.idletboth_event_handlers=[prevent_default;stop_propagation]letclick_on?(shift_key_down=false)?(ctrl_key_down=false)?(alt_key_down=false)node=trigger~event_name:"onclick"node~extra_fields:(("shiftKey",Js.Unsafe.inject(Js.boolshift_key_down))::("ctrlKey",Js.Unsafe.inject(Js.boolctrl_key_down))::("altKey",Js.Unsafe.inject(Js.boolalt_key_down))::both_event_handlers);;letfocusnode=trigger~event_name:"onfocus"node~extra_fields:both_event_handlersletblurnode=trigger~event_name:"onblur"node~extra_fields:both_event_handlerslettag_name_exn=function|Element{tag_name;_}->tag_name|other->letnode=to_string_htmlotherinraise_s[%message(node:string)"is not an element"];;letbuild_target~element~value=(* When an [on_input] event is fired, in order to pull the value of
the element, [Virtual_dom.Vdom.Attr.on_input_event] looks at the
"target" property on the event and tries to coerce that value to one
of [input element, select element, textarea element]. This coercion
function is implemented in [Js_of_ocaml.Dom_html.CoerceTo], and the
way that the coercion function works is by comparing the value of
the [tagName] property on the event target to the string of the tag
name that the coercion is targeting.
By mocking out the [tagName] and [value] properties on the target of
the event, we can trick the virtual_dom code into handling our event
as though there was a real DOM element! *)Js.Unsafe.inject(object%jsvaltagName=Js.string(tag_name_exnelement)valvalue=Js.stringvalueend);;letset_checkboxelement~checked=lettarget=(* Similarly to [build_target] we inject a target field with some additional
attributes that are relied upon -- in this case by
Bonsai_web_ui_form.Elements.checkbox, which is a common way to construct checkbox
elements. *)Js.Unsafe.inject(object%jsvaltagName=Js.string(tag_name_exnelement)valchecked=Js.boolcheckedend)intriggerelement~event_name:"onclick"~extra_fields:(("target",target)::both_event_handlers);;letinput_textelement~text=lettarget=build_target~element~value:textinletextra_fields=["target",target]inletevent_names=["oninput";"onchange"]intrigger_manyelement~extra_fields~event_names;;letkeydown?(shift_key_down=false)?(ctrl_key_down=false)?(alt_key_down=false)element~key=letopenVdom_keyboardinletkey_code=Keystroke.Keyboard_code.to_key_codekeyinletlocation=Keystroke.Keyboard_code.to_locationkeyinletint_to_anyx=Js.Unsafe.coerce(Js.number_of_float(Int.to_floatx))inletextra_fields=["location",int_to_anylocation;"keyCode",int_to_anykey_code;"code",Js.Unsafe.coerce(Js.string"");"key",Js.Unsafe.coerce(Js.string"");"shiftKey",Js.Unsafe.coerce(Js.boolshift_key_down);"ctrlKey",Js.Unsafe.coerce(Js.boolctrl_key_down);"metaKey",Js.Unsafe.coerce(Js.boolalt_key_down);("preventDefault",Js.Unsafe.inject(Js.wrap_callback(fun_->print_s[%message"default prevented"(key:Keystroke.Keyboard_code.t)])))]inletevent_names=["onkeydown"]intrigger_manyelement~extra_fields~event_names;;letenterelement=triggerelement~event_name:"ondragenter"~extra_fields:both_event_handlers;;letoverelement=triggerelement~event_name:"ondragover"~extra_fields:both_event_handlers;;letsubmit_formelement=triggerelement~event_name:"onsubmit"~extra_fields:both_event_handlers;;letchangeelement~value=lettarget=build_target~element~valueintriggerelement~event_name:"onchange"~extra_fields:(("target",target)::both_event_handlers);;letdragelement=triggerelement~event_name:"ondragstart"~extra_fields:["offsetX",Js.Unsafe.inject0;"offsetY",Js.Unsafe.inject0];;letleaveelement=triggerelement~event_name:"ondragleave"letdropelement=triggerelement~event_name:"ondrop"~extra_fields:["clientX",Js.Unsafe.inject0;"clientY",Js.Unsafe.inject0];;letend_element=triggerelement~event_name:"ondragend"letmousemoveelement=triggerelement~event_name:"onmousemove"end