123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345open!Core_kernelopenVirtual_domopenAsync_kernelopenJs_of_ocamllettimer_starts~debug=ifdebugthenFirebug.console##time(Js.strings)lettimer_stops~debug=ifdebugthenFirebug.console##timeEnd(Js.strings)(** [request_animation_frame] notifies the browser that you would like to do some
computation before the next repaint. Because this needs to occur in the same
synchronous call (called before the next repaint), returning a Deferred.t will not
work. Instead, you pass in a job to be run before the repaint.
Note that if [callback] contains any asynchronous work before doing DOM changes, those
changes will not be included in the repaint and will be saved until the following one.
*)letrequest_animation_framecallback=letmoduleScheduler=Async_kernel_schedulerin(* We capture the current context to use it later when handling callbacks from
requestAnimationFrame, since exceptions raised to that would otherwise not go through
our ordinary Async monitor. *)letcurrent_context=Scheduler.current_execution_context()inletcallback_timestamp=letcallback_result=Scheduler.within_contextcurrent_contextcallbackinignore(callback_result:(unit,unit)Result.t)inletwrapped_callback=Js.wrap_callbackcallbackinletrequest_result=Dom_html.window##requestAnimationFramewrapped_callbackinignore(request_result:Dom_html.animation_frame_request_id);;(** The Js_of_ocaml type Dom_html.element doesn't have the correct options for
their `focus` method. Cast to this in order to work around this bug. *)typefocusable=<focus:<preventScroll:boolJs.tJs.readonly_prop>Js.t->unitJs.meth>letas_focusable:Dom_html.elementJs.t->focusableJs.t=Js.Unsafe.coerce(** [Visibility] encapsulates the dirtying and cleaning of the visibility flag
The viewport starts out dirty. When we look at the DOM to compute what is visible by
calling [update_visibility], the viewport then becomes clean. Any time the user
scrolls our page or resizes the window, the viewport becomes dirty again. If we update
the DOM, the viewport becomes dirty again because a DOM update could cause a reflow,
moving the elements that are visible.
We have implemented this as a flag instead of requiring the users to send an action on
scroll because it would make no sense to compute the visibility on the virtual-dom
when the virtual-dom does not match the actual dom (as it is in the middle of
[apply_actions]). *)moduleVisibility:sigtypetvalcreate_as_dirty:unit->tvalmark_clean:t->unitvalmark_dirty:t->unitvalis_dirty:t->bool(** returns a deferred that becomes determined next time we're dirty, so immediately if
it's already dirty. *)valwhen_dirty:t->unitDeferred.tend=structtypet={mutablewhen_dirty:unitIvar.t}letcreate_as_dirty()={when_dirty=Ivar.create_full()}letmark_dirtyt=Ivar.fill_if_emptyt.when_dirty()letis_dirtyt=Ivar.is_fullt.when_dirtyletwhen_dirtyt=Ivar.readt.when_dirtyletmark_cleant=ifis_dirtytthent.when_dirty<-Ivar.create()endmoduleAction_log:sigvalinit:unit->unitvalshould_log:unit->boolend=structclasstypeglobal=objectmethodlogFlag:boolJs.tJs.writeonly_propmethodlogFlag_untyped:'aJs.tJs.optdefJs.readonly_propmethodstartLogging:(unit->unit)Js.callbackJs.writeonly_propmethodstopLogging:(unit->unit)Js.callbackJs.writeonly_propendletglobal:globalJs.t=Js.Unsafe.globalletinit()=letset_flagb=global##.logFlag:=Js.boolbinset_flagfalse;global##.startLogging:=Js.wrap_callback(fun()->set_flagtrue);global##.stopLogging:=Js.wrap_callback(fun()->set_flagfalse);letinit_message=" Incr_dom action logging is disabled by default.\n\
\ To start logging actions, type startLogging()\n\
\ To stop logging actions, type stopLogging()\n"inFirebug.console##log(Js.stringinit_message);;letshould_log()=Js.Optdef.caseglobal##.logFlag_untyped(Fn.constfalse)(funlog_flag->matchJs.to_string(Js.typeoflog_flag)with|"boolean"->Js.to_boollog_flag|_->false);;end(* Adds the necessary attribute to the root node so that it can intercept
keyboard events.
https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex *)letoverride_root_elementroot=letopenVdominmatch(root:Node.t)with|Elemente->letnew_element=letnew_attrs=[Attr.style(Css_gen.outline~style:`None());Attr.tabindex0]inNode.Element.map_attrse~f:(funattrs->Attrs.merge_classes_and_styles(new_attrs@attrs))inNode.Elementnew_element|None|Text_|Widget_->root;;letget_tag_name(node:Vdom.Node.t)=matchnodewith|Elemente->Some(Vdom.Node.Element.tage)|None|Text_|Widget_->None;;letstart(typemodel)?(debug=false)?(stop=Deferred.never())~bind_to_element_with_id~initial_model(moduleApp:App_intf.SwithtypeModel.t=model)=(* This is idempotent and so fine to do. *)Async_js.init();don't_wait_for(let%bind()=Async_js.document_loaded()inletmodel_v=Incr.Var.createinitial_modelinletmodel=Incr.Var.watchmodel_vinletmodel_from_last_display_v=Incr.Var.createinitial_modelinletmodel_from_last_display=Incr.Var.watchmodel_from_last_display_vinIncr.set_cutoffmodel(Incr.Cutoff.create(fun~old_value~new_value->App.Model.cutoffold_valuenew_value));letr,w=Pipe.create()inletschedule_actionaction=Pipe.write_without_pushbackwactioninletmoduleEvent=Vdom.Event.Define(structmoduleAction=App.Actionlethandleaction=Pipe.write_without_pushbackwactionend)inletvisibility=Visibility.create_as_dirty()inletviewport_changed()=Visibility.mark_dirtyvisibilityin(* This registers the [viewport_changed] handler with Virtual_dom. If event handlers
use the [Vdom.Event.Viewport_changed] event, we are notified. *)letmoduleViewport_handler=Vdom.Event.Define_visibility(structlethandle=viewport_changedend)inletapp=Incr.observe(App.createmodel~old_model:model_from_last_display~inject:Event.inject)inIncr.stabilize();Action_log.init();lethtml=Incr.Observer.value_exnapp|>Component.viewinlethtml_dom=Vdom.Node.to_domhtmlinletelem=Dom_html.getElementById_exnbind_to_element_with_idinletparent=Option.value_exn~here:[%here](Js.Opt.to_optionelem##.parentNode)inDom.replaceChildparenthtml_domelem;(* we make sure to call [viewport_changed] whenever the window resizes or the scroll
container in which our HTML is located is scrolled. *)letcall_viewport_changed_on_eventevent_namewhere=ignore(Dom.addEventListenerwhere(Dom.Event.makeevent_name)(Dom.handler(fun_->viewport_changed();Js._true))Js._false:Dom.event_listener_id)incall_viewport_changed_on_event"scroll"(Js_misc.get_scroll_containerhtml_dom);call_viewport_changed_on_event"resize"Dom_html.window;let%bindstate=App.on_startup~schedule_action(Incr.Var.valuemodel_v)inletprev_html=refhtmlinletprev_elt=refhtml_dominletrefocus_root_element()=letelement=!prev_eltin(* If the element to focus is an element, cast it into the
more permissive "focusable" type defined at the top of
this file, and then focus that. *)Dom_html.CoerceTo.elementelement|>Js.Opt.to_option|>Option.map~f:as_focusable|>Option.iter~f:(funelement->element##focus(object%jsvalpreventScroll=Js._trueend))in(*
Take action on any blur event, refocusing to the root node if the relatedTarget is
null or undefined, signifying that focus was lost and would otherwise be reset to
the body node.
The Js._true parameter provided to Dom.addEventListener is the useCapture
parameter described here:
https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
*)ignore@@Dom.addEventListenerDom_html.windowDom_html.Event.blur(Dom_html.handler(fune->(* [Js.Unsafe.*] is like [Obj.magic]. We should be explicit about what we
expect. *)lete:<relatedTarget:Dom_html.elementJs.tJs.optJs.readonly_prop>Js.t=Js.Unsafe.coerceeinletrelated_target=e##.relatedTargetinifnot(Js.Opt.testrelated_target)thenrefocus_root_element();Js._true))Js._true;letupdate_visibility()=Visibility.mark_cleanvisibility;letnew_model=Component.update_visibility(Incr.Observer.value_exnapp)~schedule_actioninIncr.Var.setmodel_vnew_model;timer_start"stabilize"~debug;Incr.stabilize();timer_stop"stabilize"~debuginletapply_actionaction=ifAction_log.should_log()thenAsync_js.Debug.log_s[%message"Action"(action:App.Action.t)];letnew_model=(app|>Incr.Observer.value_exn|>Component.apply_action)actionstate~schedule_actioninIncr.Var.setmodel_vnew_model;timer_start"stabilize"~debug;Incr.stabilize();timer_stop"stabilize"~debuginletrecapply_actionspipe=matchPipe.read_nowpipewith|`Eof->failwith"bug: Action pipe closed"|`Nothing_available->()|`Okaction->apply_actionaction;apply_actionspipeinletperform_updatepipe=timer_start"stabilize"~debug;(* The clock is set only once per call to perform_update, so that all actions that
occur before each display update occur "at the same time." *)letnow=letdate=new%jsJs.date_nowinTime_ns.Span.of_msdate##getTime|>Time_ns.of_span_since_epochinIncr.Clock.advance_clockIncr.clock~to_:now;Incr.stabilize();timer_stop"stabilize"~debug;timer_start"total"~debug;timer_start"update visibility"~debug;ifVisibility.is_dirtyvisibilitythenupdate_visibility();timer_stop"update visibility"~debug;timer_start"apply actions"~debug;apply_actionspipe;timer_stop"apply actions"~debug;lethtml=Incr.Observer.value_exnapp|>Component.viewinlethtml=override_root_elementhtmlintimer_start"diff"~debug;letpatch=Vdom.Node.Patch.create~previous:!prev_html~current:htmlintimer_stop"diff"~debug;ifnot(Vdom.Node.Patch.is_emptypatch)thenVisibility.mark_dirtyvisibility;timer_start"patch"~debug;letelt=Vdom.Node.Patch.applypatch!prev_eltintimer_stop"patch"~debug;timer_start"on_display"~debug;Component.on_display(Incr.Observer.value_exnapp)state~schedule_action;timer_stop"on_display"~debug;Incr.Var.setmodel_from_last_display_v(Incr.Var.valuemodel_v);letold_tag_name=get_tag_name!prev_htmlinletnew_tag_name=get_tag_namehtmlinlettags_the_same=Option.equalString.equalold_tag_namenew_tag_nameinprev_html:=html;prev_elt:=elt;timer_stop"total"~debug;ifdebugthenFirebug.console##debug(Js.string"-------");(* Changing the tag name causes focus to be lost. Refocus in that case. *)ifnottags_the_samethenrefocus_root_element()in(* We use [request_animation_frame] so that browser tells us where it's time to
refresh the UI. All the actions will be processed and the changes propagated
to the DOM in one frame. *)letreccallback()=ifDeferred.is_determinedstopthen()elseif(not(Visibility.is_dirtyvisibility))&&Pipe.is_emptyrthendon't_wait_for(* Wait until actions have been enqueued before scheduling an animation frame *)(let%map()=Deferred.any_unit[Deferred.ignore_m(Pipe.values_availabler:[`Eof|`Ok]Deferred.t);Visibility.when_dirtyvisibility]inrequest_animation_framecallback)else(perform_updater;request_animation_framecallback)in(* We want the root element to start out focused, so perform an initial
update/render, then immediately focus the root (unless a non-body element already
has focus). This focusing can't happen inside of the `callback` because then it
would refocus root every frame. *)perform_updater;(matchJs.Opt.to_optionDom_html.document##.activeElementwith|Someel->ifJs.Opt.test(Dom_html.CoerceTo.bodyel)thenrefocus_root_element()|None->refocus_root_element());request_animation_framecallback;Deferred.never());;