From bf94695abeda0d7bb296ae4cd0f9a53782587d4a Mon Sep 17 00:00:00 2001 From: Sébastien Dailly Date: Mon, 7 Feb 2022 16:14:09 +0100 Subject: Update editor organisation --- editor/actions.ml | 23 ++-- editor/app.ml | 118 ++++++++++++++++++++ editor/dune | 2 + editor/editor.ml | 197 +++++---------------------------- editor/footnotes.ml | 248 ------------------------------------------ editor/forms/add_page.ml | 22 +++- editor/forms/add_page.mli | 6 +- editor/forms/delete_page.ml | 10 +- editor/forms/dune | 1 + editor/forms/events.ml | 17 ++- editor/link_editor.ml | 127 --------------------- editor/plugins.ml | 135 ----------------------- editor/plugins/dune | 9 ++ editor/plugins/footnotes.ml | 248 ++++++++++++++++++++++++++++++++++++++++++ editor/plugins/link_editor.ml | 127 +++++++++++++++++++++ editor/plugins/plugins.ml | 137 +++++++++++++++++++++++ editor/plugins/popin.ml | 83 ++++++++++++++ editor/plugins/tooltip.ml | 89 +++++++++++++++ editor/popin.ml | 83 -------------- editor/state/dune | 9 ++ editor/state/state.ml | 70 ++++++++++++ editor/state/state.mli | 24 ++++ editor/state/storage.ml | 137 +++++++++++++++++++++++ editor/state/storage.mli | 36 ++++++ editor/storage.ml | 137 ----------------------- editor/storage.mli | 36 ------ editor/tooltip.ml | 89 --------------- 27 files changed, 1176 insertions(+), 1044 deletions(-) create mode 100755 editor/app.ml delete mode 100755 editor/footnotes.ml delete mode 100755 editor/link_editor.ml delete mode 100755 editor/plugins.ml create mode 100755 editor/plugins/dune create mode 100755 editor/plugins/footnotes.ml create mode 100755 editor/plugins/link_editor.ml create mode 100755 editor/plugins/plugins.ml create mode 100755 editor/plugins/popin.ml create mode 100755 editor/plugins/tooltip.ml delete mode 100755 editor/popin.ml create mode 100755 editor/state/dune create mode 100755 editor/state/state.ml create mode 100755 editor/state/state.mli create mode 100755 editor/state/storage.ml create mode 100755 editor/state/storage.mli delete mode 100755 editor/storage.ml delete mode 100755 editor/storage.mli delete mode 100755 editor/tooltip.ml diff --git a/editor/actions.ml b/editor/actions.ml index f7633e1..0f107f9 100755 --- a/editor/actions.ml +++ b/editor/actions.ml @@ -17,30 +17,24 @@ let populate_menu () = let delete_button = El.button ~at:At.[ class' (Jstr.v "action-button") ] - [ El.i - [] + [ El.i [] ~at:At.[ class' (Jstr.v "fa") ; class' (Jstr.v "fa-2x") - ; class' (Jstr.v "fa-trash") - ] ] + ; class' (Jstr.v "fa-trash") ] ] and home_button = El.button ~at:At.[ class' (Jstr.v "action-button") ] - [ El.i - [] + [ El.i [] ~at:At.[ class' (Jstr.v "fa") ; class' (Jstr.v "fa-2x") - ; class' (Jstr.v "fa-home") - ] ] + ; class' (Jstr.v "fa-home") ] ] and add_button = El.button ~at:At.[ class' (Jstr.v "action-button") ] - [ El.i - [] + [ El.i [] ~at:At.[ class' (Jstr.v "fa") ; class' (Jstr.v "fa-2x") - ; class' (Jstr.v "fa-plus") - ] ] + ; class' (Jstr.v "fa-plus") ] ] in @@ -49,19 +43,20 @@ let populate_menu () = Ev.click Evr.unit delete_button + and add_event = Evr.on_el Ev.click Evr.unit add_button in - let stored_pages = Storage.get_ids () in + let stored_pages = State.Storage.get_ids () in let pages = List.map stored_pages ~f:(fun id -> - let name_opt = (Storage.load (Some id))##.title in + let name_opt = (State.Storage.load (Some id))##.title in let name = Js.Opt.get name_opt (fun () -> id) in diff --git a/editor/app.ml b/editor/app.ml new file mode 100755 index 0000000..aee396a --- /dev/null +++ b/editor/app.ml @@ -0,0 +1,118 @@ +open Brr +module PM = Prosemirror +module Js = Js_of_ocaml.Js + +type events = + | DeleteEvent + | StoreEvent + | LoadEvent of Jstr.t option + | AddEvent + | CloseEvent of Forms.Events.kind option + | GEvent of Forms.Events.event + +let key_of_title + : Jstr.t -> Jstr.t + = fun title -> + title + +(** [update] is the event loop. + + The function take a new event, and apply it to the current state. *) + +let update + : 'a option Note.E.send -> (events, State.t) Application.t + = fun close_sender event state -> + match event with + + | GEvent (Event (t, (module Handler))) -> + Handler.on_close t state + + | AddEvent -> + let title = Jstr.v "Nouvelle page" in + let popup = Ui.popup + ~title + ~form:(Some (Forms.Add_page.create ())) + close_sender in + { state with window = popup::state.window} + + | DeleteEvent -> + begin match state.page_id with + | None -> state + | Some page_id -> + let title = Jstr.v "Confirmation" in + let popup = Ui.popup + ~title + ~form:(Some (Forms.Delete_page.create page_id)) + close_sender in + { state with window = popup::state.window} + end + + | CloseEvent res -> + + let state = match state.window with + | [] -> { state with window = [] } + | el::tl -> El.remove el + ; { state with window = tl } in + + (* The actions is confirmed by the user. Handle the form result *) + begin match res with + (* Delete the current page, then load the home page *) + | Some (Forms.Delete_page.DeletePage id) -> + State.Storage.delete (fun () -> Some id); + let json = State.Storage.load None in + State.load_page None state json + (* Add a new page *) + | Some (Forms.Add_page.AddPage {title}) -> + let page_id = key_of_title title in + let new_date = (new%js Js.date_now)##getTime in + let content_obj = object%js + val content = Js.null + val title = Js.some title + val date = Js.some new_date + end in + State.load_page (Some page_id) state content_obj + + | _ -> state + end + + | StoreEvent -> + + let title_element = Document.find_el_by_id G.document (Jstr.v "title") in + let content = Option.map + (fun el -> El.prop (El.Prop.value) el) + title_element in + + let new_date = (new%js Js.date_now)##getTime in + let content_obj = object%js + val content = Js.some @@ Jv.Id.to_jv (state.view##.state##toJSON ()) + val title = Js.Opt.option content + val date = Js.some new_date + end in + let save = State.Storage.save + content_obj + state.page_id + ~check:(fun previous_state -> + Js.Opt.case previous_state##.date + (fun () -> true) + (fun date -> + (* I do not figure how the previous date could be older + than the last backup. It could be either : + + - equal (if we are the only one to update it) + - more recent (if the content has been updated elsewhere) + + but older shoud be a bug. *) + date <= state.last_backup)) in + begin match save with + | Ok true -> { state with last_backup = new_date } + | other -> + (* TODO In case of error, notify the user *) + Console.(log [other]); + state + end + + | LoadEvent page_id -> + let json = State.Storage.load page_id in + State.load_page page_id state json + + diff --git a/editor/dune b/editor/dune index c8dfe3c..295c39f 100755 --- a/editor/dune +++ b/editor/dune @@ -8,6 +8,8 @@ prosemirror blog application + state + plugins forms ) (modes js) diff --git a/editor/editor.ml b/editor/editor.ml index d3a9624..1a34dfc 100755 --- a/editor/editor.ml +++ b/editor/editor.ml @@ -2,56 +2,6 @@ open Brr module PM = Prosemirror module Js = Js_of_ocaml.Js -(** This is the state for the application *) -type state = - { editable : bool - ; view : PM.View.editor_view Js.t - ; last_backup: float - ; page_id: Jstr.t option - - ; window : El.t list - } - -type events = - | DeleteEvent - | StoreEvent - | LoadEvent of Jstr.t option - | AddEvent - | CloseEvent of Forms.Events.kind option - -let set_title - : Storage.content Js.t -> unit - = fun content -> - let title = - Js.Opt.get - content##.title - (fun () -> Jstr.empty) in - let title_element = Document.find_el_by_id G.document (Jstr.v "title") in - Option.iter - (fun el -> El.set_prop (El.Prop.value) title el) - title_element - -let key_of_title - : Jstr.t -> Jstr.t - = fun title -> - title - -let state_of_storage - : PM.t -> Storage.content Js.t -> PM.Model.schema Js.t -> PM.State.editor_state Js.t - = fun pm content schema -> - Js.Opt.case - content##.content - (fun () -> - let obj = PM.State.creation_prop () in - obj##.plugins := Plugins.default pm schema; - obj##.schema := Js.some schema; - PM.State.create pm obj) - (fun page_content -> - let obj = PM.State.configuration_prop () in - obj##.plugins := Plugins.default pm schema; - obj##.schema := Js.some schema; - PM.State.fromJSON pm obj page_content) - (** Create a new editor view [build_view element state] will create the editor and attach it to [element]. @@ -68,7 +18,7 @@ let build_view This could be improved, instead of creating a new schema, just fetch the node and marks from the plungin *) let custom_schema = - Footnotes.footnote_schema + Plugins.Footnotes.footnote_schema pm (PM.SchemaBasic.schema pm) in @@ -83,7 +33,7 @@ let build_view (Some custom_schema##.spec##.marks) None in let full_schema = PM.Model.schema pm specs in - let stored_content = Storage.load page_id in + let stored_content = State.Storage.load page_id in (* This variable contains the last update time, either because it is stored, or because it is the date where we create the first page. *) @@ -92,11 +42,11 @@ let build_view (fun () -> (new%js Js.date_now)##getTime) in let props = PM.View.direct_editor_props () in - props##.state := state_of_storage pm stored_content full_schema; + props##.state := State.state_of_storage pm stored_content full_schema; (* Add the custom nodes *) props##.nodeViews := PM.O.init - [| ( "footnote", (Footnotes.footnote_view pm)) + [| ( "footnote", (Plugins.Footnotes.footnote_view pm)) |]; let view = PM.View.editor_view @@ -105,114 +55,21 @@ let build_view props in view, last_backup -let load_page - : PM.t -> Jstr.t option -> state -> Storage.content Js.t -> state - = fun pm page_id state json -> - let editor_state = state_of_storage pm json state.view##.state##.schema in - let () = state.view##updateState editor_state - and () = set_title json in - { state with page_id } (** [update] is the event loop. The function take a new event, and apply it to the current state. *) let update - : PM.t -> 'a option Note.E.send -> (events, state) Application.t - = fun pm close_sender event state -> - match event with - - | AddEvent -> - let title = Jstr.v "Nouvelle page" in - let popup = Ui.popup - ~title - ~form:(Some (Forms.Add_page.create ())) - close_sender in - { state with window = popup::state.window} - - | DeleteEvent -> - begin match state.page_id with - | None -> state - | Some page_id -> - let title = Jstr.v "Confirmation" in - let popup = Ui.popup - ~title - ~form:(Some (Forms.Delete_page.create page_id)) - close_sender in - { state with window = popup::state.window} - end - - | CloseEvent res -> - - let state = match state.window with - | [] -> { state with window = [] } - | el::tl -> El.remove el - ; { state with window = tl } in - - (* The actions is confirmed by the user. Handle the form result *) - begin match res with - (* Delete the current page, then load the home page *) - | Some (Forms.Delete_page.DeletePage id) -> - Storage.delete (fun () -> Some id); - let json = Storage.load None in - load_page pm None state json - (* Add a new page *) - | Some (Forms.Add_page.AddPage {title}) -> - let page_id = key_of_title title in - Console.(log [title]); - let new_date = (new%js Js.date_now)##getTime in - let content_obj = object%js - val content = Js.null - val title = Js.some title - val date = Js.some new_date - end in - load_page pm (Some page_id) state content_obj - - | _ -> state - end - - | StoreEvent -> - - let title_element = Document.find_el_by_id G.document (Jstr.v "title") in - let content = Option.map - (fun el -> El.prop (El.Prop.value) el) - title_element in - - let new_date = (new%js Js.date_now)##getTime in - let content_obj = object%js - val content = Js.some @@ Jv.Id.to_jv (state.view##.state##toJSON ()) - val title = Js.Opt.option content - val date = Js.some new_date - end in - let save = Storage.save - content_obj - state.page_id - ~check:(fun previous_state -> - Js.Opt.case previous_state##.date - (fun () -> true) - (fun date -> - (* I do not figure how the previous date could be older - than the last backup. It could be either : - - - equal (if we are the only one to update it) - - more recent (if the content has been updated elsewhere) - - but older shoud be a bug. *) - date <= state.last_backup)) in - begin match save with - | Ok true -> { state with last_backup = new_date } - | _ -> - (* TODO In case of error, notify the user *) - state - end - - | LoadEvent page_id -> - let json = Storage.load page_id in - load_page pm page_id state json - + : 'a option Note.E.send -> (App.events, State.t) Application.t + = App.update let app id content = + (* This event is used in the pop process. The sender is given to the + subroutine in order to track the window closing *) + let event, sender = Note.E.create () in + (* Check the pre-requisite *) let events_opt = Actions.populate_menu () in match (Jv.is_none id), (Jv.is_none content), events_opt with @@ -221,34 +78,32 @@ let app id content = let pm = PM.v () in let editor:El.t = Jv.Id.of_jv id in (* Load the cache for the given page *) - let page_id = Storage.page_id () in + let page_id = State.Storage.page_id () in let view, last_backup = build_view pm page_id editor in - (* This event is used in the pop process. The sender is given to the - subroutine in order to track the window closing *) - let event, sender = Note.E.create () in - let _ = sender in let init_state = - { editable = true - ; view - ; last_backup - ; page_id - - ; window = [] - } + State.{ editable = true + ; view + ; last_backup + ; page_id + + ; window = [] + ; pm + } in let app_state = Application.run - (update pm sender) + ~eq:State.eq + (App.update sender) init_state (Note.E.select - [ Note.E.map (fun () -> DeleteEvent) btn_events.Actions.delete - ; Brr_note.Evr.on_el Ev.focusout (fun _ -> StoreEvent) editor - ; Note.E.map (fun v -> LoadEvent v) btn_events.Actions.redirect - ; Note.E.map (fun () -> AddEvent) btn_events.Actions.add - ; Note.E.map (fun v -> CloseEvent v) event + [ Brr_note.Evr.on_el Ev.focusout (fun _ -> App.StoreEvent) editor + ; Note.E.map (fun () -> App.DeleteEvent) btn_events.Actions.delete + ; Note.E.map (fun () -> App.AddEvent) btn_events.Actions.add + ; Note.E.map (fun v -> App.LoadEvent v) btn_events.Actions.redirect + ; Note.E.map (fun v -> App.CloseEvent v) event ]) in let () = diff --git a/editor/footnotes.ml b/editor/footnotes.ml deleted file mode 100755 index 794171f..0000000 --- a/editor/footnotes.ml +++ /dev/null @@ -1,248 +0,0 @@ -open Brr -open Js_of_ocaml -module PM = Prosemirror - -let footNoteSpec = object%js - - val mutable group = Jstr.v "inline" - val mutable content = Jstr.v "inline*" (* The star is very important ! *) - val mutable inline = Js._true - val mutable draggable = Js._true - (* This makes the view treat the node as a leaf, even though it - technically has content *) - val mutable atom = Js._true - - val toDOM - : (PM.Model.node Js.t -> PM.Model.domOutputSpec Js.t) Js.callback - = Js.wrap_callback (fun _ -> - let open PM.Model.Dom_output_spec in - v "footnote" - [ hole ]) - - val parseDOM - : PM.Model.parse_rule Js.t Js.js_array Js.t Js.opt - = Js.some @@ Js.array - [|PM.Model.ParseRule.tag (Jstr.v "footnote")|] - -end - -let footnote_schema pm defaultSchema = - - let nodes = defaultSchema##.spec##.nodes - and marks = defaultSchema##.spec##.marks in - - let specs = PM.Model.schema_spec - (nodes##addToEnd (Jstr.v "footnote") (Js.Unsafe.coerce footNoteSpec)) - (Some marks) - None in - - PM.Model.schema pm - specs - -let build_menu pm schema = - let menu = PM.Example.buildMenuItems pm schema in - - let itemSpec = PM.Menu.menuItemSpec () in - itemSpec##.title := Js.some @@ Jstr.v "Insert footnote"; - itemSpec##.label := Js.some @@ Jstr.v "Footnote"; - itemSpec##.select := Js.wrap_meth_callback (fun _ (state:PM.State.editor_state Js.t) -> - match PM.O.get schema##.nodes "footnote" with - | None -> Js._false - | Some footnote_node -> - let res = Js.Opt.test @@ PM.Transform.insertPoint - pm - state##.doc - ~pos:state##.selection##.from - footnote_node - in - Js.bool res); - - itemSpec##.run := - Js.wrap_meth_callback (fun _this state dispatch _ _ -> - match PM.O.get schema##.nodes "footnote" with - | None -> () - | Some footnote_node -> - - let from' = PM.State.selection_from state##.selection - and to' = PM.State.selection_to state##.selection in - - let content = - if state##.selection##.empty != Js._true - && from'##sameParent to' = Js._true - && from'##.parent##.inlineContent = Js._true then ( - from'##.parent##.content##cut - (from'##.parentOffset) - (Js.some @@ to'##.parentOffset) - ) else ( - PM.Model.empty_fragment pm - ) in - let new_node = footnote_node##create_withFragmentContent - Js.null - (Js.some content) - Js.null - in - dispatch @@ - state##.tr##replaceSelectionWith - new_node - Js.null - ); - - let item = PM.Menu.menu_item pm itemSpec in - let _ = menu##.insertMenu##.content##push item in - menu - -let fromOutside - : bool PM.State.meta_data Js.t - = PM.State.create_str_meta_data (Jstr.v "fromOutside") - -let footnote_view - : PM.t -> PM.Model.node Js.t -> PM.View.editor_view Js.t -> (unit -> int) -> < .. > Js.t - = fun pm init_node outerView get_pos -> - - (* These are used when the footnote is selected *) - let innerView - : PM.View.editor_view Js.t Js.opt ref - = ref Js.null in - - let dispatchInner - : PM.View.editor_view Js.t -> PM.State.transaction Js.t -> unit - = fun view tr -> - let res = view##.state##applyTransaction tr in - view##updateState res##.state; - - let meta = Js.Optdef.get (tr##getMeta fromOutside) (fun () -> false) in - if (not meta) then ( - let outerTr = outerView##.state##.tr - and offsetMap = PM.Transform.offset pm ((get_pos()) + 1) in - res##.transactions##forEach - (Js.wrap_callback @@ - fun (elem:PM.State.transaction Js.t) _ _ -> - elem##.steps##forEach - (Js.wrap_callback @@ fun (step:PM.Transform.step Js.t) _ _ -> - let _ = outerTr##step (step##map offsetMap) in - () - )); - if (outerTr##.docChanged = Js._true) then ( - outerView##dispatch outerTr) - ); - in - object%js (_self) - - val mutable node: PM.Model.node Js.t = init_node - - (* The node's representation in the editor (empty, for now) *) - val dom = El.v (Jstr.v "footnote") [] - - method _open = - (* Append a tooltip to the outer node *) - let tooltip = El.div [] - ~at:At.([class' (Jstr.v "popin")]) in - El.append_children _self##.dom - [ tooltip ]; - - let dispatch_fn - : PM.State.transaction Js.t -> unit - = fun tr -> outerView##dispatch tr in - - let state_properties = Js.Unsafe.coerce (object%js - val doc = Js.some _self##.node; - val plugins = Js.some @@ Js.array @@ [| - PM.Keymap.keymap pm - [| ( "Mod-z" - , (fun _ _ -> PM.History.undo pm outerView##.state (Js.some dispatch_fn))) - ; ( "Mod-y" - , (fun _ _ -> PM.History.redo pm outerView##.state (Js.some dispatch_fn))) - |] - |]; - end) in - - let view_properties = PM.View.direct_editor_props () in - view_properties##.state := PM.State.create pm state_properties; - (* This is the magic part *) - view_properties##.dispatchTransaction := - (Js.wrap_meth_callback dispatchInner); - view_properties##.handleDOMEvents := PM.O.init - [| ( "mousedown" - , Js.wrap_callback (fun _ _ -> - (* Kludge to prevent issues due to the fact that the - whole footnote is node-selected (and thus DOM-selected) - when the parent editor is focused. *) - if (outerView##hasFocus () = Js._true) then ( - Js.Opt.iter !innerView (fun view -> view##focus ()) - ); - Js._false ))|]; - - innerView := Js.some - (PM.View.editor_view pm - tooltip - view_properties); - - method close = - Js.Opt.iter (!innerView) - (fun view -> - view##destroy; - innerView := Js.null; - El.set_prop - (El.Prop.jstr (Jstr.v "textContent")) - (Jstr.empty) - _self##.dom - ) - - method update - : PM.Model.node Js.t -> bool Js.t - = fun node -> - if (node##sameMarkup _self##.node = Js._false) then ( - Js._false - ) else ( - _self##.node := node; - Js.Opt.iter !innerView (fun view -> - let state = view##.state in - Js.Opt.iter (node##.content##findDiffStart state##.doc##.content) (fun start -> - let res_opt = (node##.content##findDiffEnd state##.doc##.content) in - Js.Opt.iter res_opt (fun end_diff -> - let overlap = start - (min end_diff##.a end_diff##.b) in - let endA, endB = - if overlap > 0 then - ( end_diff##.a + overlap - , end_diff##.b + overlap ) - else - ( end_diff##.a - , end_diff##.b ) - in - let tr = - state##.tr - ##(replace - ~from:start - ~to_:endB - (Js.some @@ node##slice ~from:start ~to_:(Js.some endA))) - ##(setMeta fromOutside true) in - view##dispatch tr))); - Js._true - ) - - method destroy = - Js.Opt.iter !innerView (fun _ -> _self##close) - - method stopEvent e = - Js.Opt.case !innerView - (fun () -> Js._false) - (fun view -> - let dom = view##.dom in - Jv.call (Jv.Id.to_jv dom) "contains" [| e##.target|] - |> Jv.Id.of_jv) - - method ignoreMutation = - Js._true - - method selectNode = - El.set_class (Jstr.v "ProseMirror-selectednode") true _self##.dom; - if not (Js.Opt.test !innerView) then ( - _self##_open - ) - - method deselectNode = - El.set_class (Jstr.v "ProseMirror-selectednode") false _self##.dom; - if (Js.Opt.test !innerView) then - _self##close - - end diff --git a/editor/forms/add_page.ml b/editor/forms/add_page.ml index 597e9d3..ac45824 100755 --- a/editor/forms/add_page.ml +++ b/editor/forms/add_page.ml @@ -1,9 +1,12 @@ open Brr open Brr_note open Note +module Js = Js_of_ocaml.Js + +type t = { title : Jstr.t } type Events.kind += - | AddPage of { title : Jstr.t } + | AddPage of t [@@unboxed] let create : unit -> Events.t @@ -34,3 +37,20 @@ let create [ input ] ] ] ) + +let key_of_title + : Jstr.t -> Jstr.t + = fun title -> + title + +let on_close + : t -> State.t -> State.t + = fun {title} state -> + let page_id = key_of_title title in + let new_date = (new%js Js.date_now)##getTime in + let content_obj = object%js + val content = Js.null + val title = Js.some title + val date = Js.some new_date + end in + State.load_page (Some page_id) state content_obj diff --git a/editor/forms/add_page.mli b/editor/forms/add_page.mli index 97b1d6c..6be1611 100755 --- a/editor/forms/add_page.mli +++ b/editor/forms/add_page.mli @@ -1,5 +1,9 @@ +type t = { title : Jstr.t } type Events.kind += - | AddPage of { title : Jstr.t } + | AddPage of t [@@unboxed] val create : unit -> Events.t + +val on_close + : t -> State.t -> State.t diff --git a/editor/forms/delete_page.ml b/editor/forms/delete_page.ml index 701162c..3328dd7 100755 --- a/editor/forms/delete_page.ml +++ b/editor/forms/delete_page.ml @@ -1,8 +1,10 @@ open Brr open Note +type t = Jstr.t + type Events.kind += - | DeletePage of Jstr.t [@@unboxed] + | DeletePage of t [@@unboxed] let create : Jstr.t -> Events.t @@ -23,3 +25,9 @@ let create , El.txt message ) +let on_close + : t -> State.t -> State.t + = fun id state -> + State.Storage.delete (fun () -> Some id); + let json = State.Storage.load None in + State.load_page None state json diff --git a/editor/forms/dune b/editor/forms/dune index 9876654..124ce01 100755 --- a/editor/forms/dune +++ b/editor/forms/dune @@ -7,6 +7,7 @@ js_lib blog application + state ) (preprocess (pps js_of_ocaml-ppx)) ) diff --git a/editor/forms/events.ml b/editor/forms/events.ml index 339e15d..f7f5711 100755 --- a/editor/forms/events.ml +++ b/editor/forms/events.ml @@ -1,5 +1,20 @@ -(** This type is designed to be extended for each form *) +(** This type is designed to be extended for each form. + + Each of them hold the values inside the form. + +*) type kind = .. +(** The signal has to be log in order to be completely working *) type t = kind Note.signal * Brr.El.t +module type Handler = sig + + type t + + val on_close: t -> State.t -> State.t + +end + +type event = Event : 'a * (module Handler with type t = 'a) -> event + diff --git a/editor/link_editor.ml b/editor/link_editor.ml deleted file mode 100755 index 9bfdfd4..0000000 --- a/editor/link_editor.ml +++ /dev/null @@ -1,127 +0,0 @@ -open Brr - -module Js = Js_of_ocaml.Js -module PM = Prosemirror - -let link_edit - : PM.View.editor_view Js.t -> < .. > Js.t - = fun view -> - - let popin = El.div [] - ~at:At.([class' (Jstr.v "popin")]) in - - El.set_inline_style El.Style.display (Jstr.v "none") popin; - - let parent = Jv.(Id.of_jv @@ get (Jv.Id.to_jv view##.dom) "parentNode") in - let () = El.append_children parent [popin] in - - let hide - : unit -> unit - = fun () -> - El.set_inline_style El.Style.display (Jstr.v "none") popin - in - - let update - : PM.View.editor_view Js.t -> PM.State.editor_state Js. t Js.opt -> unit - = fun view _state_opt -> - - let state = view##.state in - Js.Opt.case (state##.doc##nodeAt (view##.state##.selection##._to)) - (fun () -> hide ()) - (fun node -> - (* Check if we are editing a link *) - match PM.O.get state##.schema##.marks "link" with - | None -> () - | Some link_type -> - let is_present = link_type##isInSet node##.marks in - Js.Opt.case - is_present - (fun () -> hide ()) - (fun mark -> - (* Get the node's bounding position and display the popin *) - let position = state##.doc##resolve - (view##.state##.selection##._to) in - let start = position##start Js.null - and end' = position##_end Js.null in - - Popin.set_position - ~start - ~end' - view popin; - - (* Extract the value from the attribute *) - let attrs = mark##.attrs in - let href_opt = PM.O.get attrs "href" in - let href_value = Option.value - ~default:Jstr.empty - href_opt - in - - (* Create the popin content *) - let a = El.a - ~at:At.[ href href_value ] - [ El.txt href_value ] in - - let entry = Popin.build_field a - (fun new_value -> - (* The function is called when the user validate - the change in the popi. We create a new - transaction in the document by replacing the - mark with the new one. *) - if not (Jstr.equal new_value href_value) then ( - - (* Create a new attribute object for the mark in - order to keep history safe *) - let attrs' = PM.O.init - [| "href", new_value |] in - - Option.iter - (fun v -> PM.O.set attrs' "title" v) - (PM.O.get attrs "title"); - - let mark' = state##.schema##mark_fromType - link_type - (Js.some attrs') in - (* Create a transaction which update the - mark with the new value *) - view##dispatch - state - ##.tr - ##(removeMark - ~from:start - ~to_:end' - mark) - ##(addMark - ~from:start - ~to_:end' - mark') - ); - true - - ) in - - - El.set_children popin - [ entry.field - ; entry.button ]; - - )) - - and destroy () = El.remove popin in - - object%js - val update = Js.wrap_callback update - val destroy= Js.wrap_callback destroy - end - -let plugin - : PM.t -> PM.State.plugin Js.t - = fun t -> - let state = Jv.get (Jv.Id.to_jv t) "state" in - - let params = object%js - val view = (fun view -> link_edit view) - end in - - Jv.new' (Jv.get state "Plugin") [| Jv.Id.to_jv params |] - |> Jv.Id.of_jv diff --git a/editor/plugins.ml b/editor/plugins.ml deleted file mode 100755 index 91dedeb..0000000 --- a/editor/plugins.ml +++ /dev/null @@ -1,135 +0,0 @@ -module Js = Js_of_ocaml.Js -module PM = Prosemirror - -(** Commands *) - -let change_level - : PM.t -> PM.Model.resolved_pos Js.t -> int -> (int -> bool) -> PM.Commands.t - = fun pm res incr pred state dispatch -> - let parent = res##.parent in - let attributes = parent##.attrs in - - let current_level = if Jv.is_none attributes##.level then - 0 - else - attributes##.level in - let t, props = match pred current_level with - | false -> - ( PM.O.get state##.schema##.nodes "heading" - , Js.some (object%js - val level = current_level + incr - end)) - | true -> - ( PM.O.get state##.schema##.nodes "paragraph" - , Js.null) in - match t with - | None -> Js._false - | Some t -> - PM.Commands.set_block_type pm t props state dispatch - -(** Increase the title level by one when pressing # at the begining of a line *) -let handle_sharp pm state dispatch = - - let res = PM.State.selection_to (state##.selection) in - match Js.Opt.to_option res##.nodeBefore with - | Some _ -> Js._false - | None -> (* Line start *) - begin match Jstr.to_string res##.parent##._type##.name with - | "heading" -> - change_level pm res 1 (fun x -> x > 5) state dispatch - | "paragraph" -> - begin match PM.O.get state##.schema##.nodes "heading" with - | None -> Js._false - | Some t -> - let props = Js.some (object%js - val level = 1 - end) in - PM.Commands.set_block_type pm t props state dispatch - end - | _ -> Js._false - end - -let handle_backspace pm state dispatch = - - let res = PM.State.selection_to (state##.selection) in - match Js.Opt.to_option res##.nodeBefore with - | Some _ -> Js._false - | None -> (* Line start *) - begin match Jstr.to_string res##.parent##._type##.name with - | "heading" -> change_level pm res (-1) (fun x -> x <= 1) state dispatch - | _ -> Js._false - end - - -let toggle_mark - : Js.regExp Js.t -> PM.t -> string -> PM.InputRule.input_rule Js.t - = fun regExp pm mark_type_name -> - PM.InputRule.create pm - regExp - ~fn:(Js.wrap_callback @@ fun (state:PM.State.editor_state Js.t) _ ~from ~to_ -> - match PM.O.get state##.schema##.marks mark_type_name with - | None -> Js.null - | Some mark_type -> - - let m = state##.schema##mark_fromType mark_type Js.null in - - (* Delete the markup code *) - let tr = (state##.tr)##delete ~from ~to_ in - - (* Check if the mark is active at the position *) - let present = Js.Opt.bind - (PM.State.cursor (tr##.selection)) - (fun resolved -> - Js.Opt.map - (mark_type##isInSet (resolved##marks ())) - (fun _ -> resolved) - ) in - Js.Opt.case present - (fun () -> - let tr = tr##addStoredMark m in - Js.some @@ tr) - (fun _resolved -> - let tr = tr##removeStoredMark_mark m in - Js.some tr)) - -let input_rule pm = - - let bold = - toggle_mark - (new%js Js.regExp (Js.string "\\*\\*$")) - pm - "strong" - and em = - toggle_mark - (new%js Js.regExp (Js.string "//$")) - pm - "em" in - - PM.InputRule.to_plugin pm - (Js.array [| bold; em |]) - -let default pm schema = - - (** Load the history plugin *) - let _ = PM.History.(history pm (history_prop ()) ) in - - let props = PM.Example.options schema in - props##.menuBar := Js.some Js._true; - props##.floatingMenu := Js.some Js._true; - props##.menuContent := (Footnotes.build_menu pm schema)##.fullMenu; - let setup = PM.Example.example_setup pm props in - - let keymaps = - PM.Keymap.keymap pm - [| "Backspace", (handle_backspace pm) - ; "#", (handle_sharp pm) - |] in - - (* Add the custom keymaps in the list *) - let _ = setup##unshift keymaps in - let _ = setup##push (input_rule pm) in - let _ = setup##push (Tooltip.bold_plugin pm) in - let _ = setup##push (Link_editor.plugin pm) in - - - Js.some setup diff --git a/editor/plugins/dune b/editor/plugins/dune new file mode 100755 index 0000000..046dc5a --- /dev/null +++ b/editor/plugins/dune @@ -0,0 +1,9 @@ +(library + (name plugins) + (libraries + brr + prosemirror + js_lib + ) + (preprocess (pps js_of_ocaml-ppx)) + ) diff --git a/editor/plugins/footnotes.ml b/editor/plugins/footnotes.ml new file mode 100755 index 0000000..794171f --- /dev/null +++ b/editor/plugins/footnotes.ml @@ -0,0 +1,248 @@ +open Brr +open Js_of_ocaml +module PM = Prosemirror + +let footNoteSpec = object%js + + val mutable group = Jstr.v "inline" + val mutable content = Jstr.v "inline*" (* The star is very important ! *) + val mutable inline = Js._true + val mutable draggable = Js._true + (* This makes the view treat the node as a leaf, even though it + technically has content *) + val mutable atom = Js._true + + val toDOM + : (PM.Model.node Js.t -> PM.Model.domOutputSpec Js.t) Js.callback + = Js.wrap_callback (fun _ -> + let open PM.Model.Dom_output_spec in + v "footnote" + [ hole ]) + + val parseDOM + : PM.Model.parse_rule Js.t Js.js_array Js.t Js.opt + = Js.some @@ Js.array + [|PM.Model.ParseRule.tag (Jstr.v "footnote")|] + +end + +let footnote_schema pm defaultSchema = + + let nodes = defaultSchema##.spec##.nodes + and marks = defaultSchema##.spec##.marks in + + let specs = PM.Model.schema_spec + (nodes##addToEnd (Jstr.v "footnote") (Js.Unsafe.coerce footNoteSpec)) + (Some marks) + None in + + PM.Model.schema pm + specs + +let build_menu pm schema = + let menu = PM.Example.buildMenuItems pm schema in + + let itemSpec = PM.Menu.menuItemSpec () in + itemSpec##.title := Js.some @@ Jstr.v "Insert footnote"; + itemSpec##.label := Js.some @@ Jstr.v "Footnote"; + itemSpec##.select := Js.wrap_meth_callback (fun _ (state:PM.State.editor_state Js.t) -> + match PM.O.get schema##.nodes "footnote" with + | None -> Js._false + | Some footnote_node -> + let res = Js.Opt.test @@ PM.Transform.insertPoint + pm + state##.doc + ~pos:state##.selection##.from + footnote_node + in + Js.bool res); + + itemSpec##.run := + Js.wrap_meth_callback (fun _this state dispatch _ _ -> + match PM.O.get schema##.nodes "footnote" with + | None -> () + | Some footnote_node -> + + let from' = PM.State.selection_from state##.selection + and to' = PM.State.selection_to state##.selection in + + let content = + if state##.selection##.empty != Js._true + && from'##sameParent to' = Js._true + && from'##.parent##.inlineContent = Js._true then ( + from'##.parent##.content##cut + (from'##.parentOffset) + (Js.some @@ to'##.parentOffset) + ) else ( + PM.Model.empty_fragment pm + ) in + let new_node = footnote_node##create_withFragmentContent + Js.null + (Js.some content) + Js.null + in + dispatch @@ + state##.tr##replaceSelectionWith + new_node + Js.null + ); + + let item = PM.Menu.menu_item pm itemSpec in + let _ = menu##.insertMenu##.content##push item in + menu + +let fromOutside + : bool PM.State.meta_data Js.t + = PM.State.create_str_meta_data (Jstr.v "fromOutside") + +let footnote_view + : PM.t -> PM.Model.node Js.t -> PM.View.editor_view Js.t -> (unit -> int) -> < .. > Js.t + = fun pm init_node outerView get_pos -> + + (* These are used when the footnote is selected *) + let innerView + : PM.View.editor_view Js.t Js.opt ref + = ref Js.null in + + let dispatchInner + : PM.View.editor_view Js.t -> PM.State.transaction Js.t -> unit + = fun view tr -> + let res = view##.state##applyTransaction tr in + view##updateState res##.state; + + let meta = Js.Optdef.get (tr##getMeta fromOutside) (fun () -> false) in + if (not meta) then ( + let outerTr = outerView##.state##.tr + and offsetMap = PM.Transform.offset pm ((get_pos()) + 1) in + res##.transactions##forEach + (Js.wrap_callback @@ + fun (elem:PM.State.transaction Js.t) _ _ -> + elem##.steps##forEach + (Js.wrap_callback @@ fun (step:PM.Transform.step Js.t) _ _ -> + let _ = outerTr##step (step##map offsetMap) in + () + )); + if (outerTr##.docChanged = Js._true) then ( + outerView##dispatch outerTr) + ); + in + object%js (_self) + + val mutable node: PM.Model.node Js.t = init_node + + (* The node's representation in the editor (empty, for now) *) + val dom = El.v (Jstr.v "footnote") [] + + method _open = + (* Append a tooltip to the outer node *) + let tooltip = El.div [] + ~at:At.([class' (Jstr.v "popin")]) in + El.append_children _self##.dom + [ tooltip ]; + + let dispatch_fn + : PM.State.transaction Js.t -> unit + = fun tr -> outerView##dispatch tr in + + let state_properties = Js.Unsafe.coerce (object%js + val doc = Js.some _self##.node; + val plugins = Js.some @@ Js.array @@ [| + PM.Keymap.keymap pm + [| ( "Mod-z" + , (fun _ _ -> PM.History.undo pm outerView##.state (Js.some dispatch_fn))) + ; ( "Mod-y" + , (fun _ _ -> PM.History.redo pm outerView##.state (Js.some dispatch_fn))) + |] + |]; + end) in + + let view_properties = PM.View.direct_editor_props () in + view_properties##.state := PM.State.create pm state_properties; + (* This is the magic part *) + view_properties##.dispatchTransaction := + (Js.wrap_meth_callback dispatchInner); + view_properties##.handleDOMEvents := PM.O.init + [| ( "mousedown" + , Js.wrap_callback (fun _ _ -> + (* Kludge to prevent issues due to the fact that the + whole footnote is node-selected (and thus DOM-selected) + when the parent editor is focused. *) + if (outerView##hasFocus () = Js._true) then ( + Js.Opt.iter !innerView (fun view -> view##focus ()) + ); + Js._false ))|]; + + innerView := Js.some + (PM.View.editor_view pm + tooltip + view_properties); + + method close = + Js.Opt.iter (!innerView) + (fun view -> + view##destroy; + innerView := Js.null; + El.set_prop + (El.Prop.jstr (Jstr.v "textContent")) + (Jstr.empty) + _self##.dom + ) + + method update + : PM.Model.node Js.t -> bool Js.t + = fun node -> + if (node##sameMarkup _self##.node = Js._false) then ( + Js._false + ) else ( + _self##.node := node; + Js.Opt.iter !innerView (fun view -> + let state = view##.state in + Js.Opt.iter (node##.content##findDiffStart state##.doc##.content) (fun start -> + let res_opt = (node##.content##findDiffEnd state##.doc##.content) in + Js.Opt.iter res_opt (fun end_diff -> + let overlap = start - (min end_diff##.a end_diff##.b) in + let endA, endB = + if overlap > 0 then + ( end_diff##.a + overlap + , end_diff##.b + overlap ) + else + ( end_diff##.a + , end_diff##.b ) + in + let tr = + state##.tr + ##(replace + ~from:start + ~to_:endB + (Js.some @@ node##slice ~from:start ~to_:(Js.some endA))) + ##(setMeta fromOutside true) in + view##dispatch tr))); + Js._true + ) + + method destroy = + Js.Opt.iter !innerView (fun _ -> _self##close) + + method stopEvent e = + Js.Opt.case !innerView + (fun () -> Js._false) + (fun view -> + let dom = view##.dom in + Jv.call (Jv.Id.to_jv dom) "contains" [| e##.target|] + |> Jv.Id.of_jv) + + method ignoreMutation = + Js._true + + method selectNode = + El.set_class (Jstr.v "ProseMirror-selectednode") true _self##.dom; + if not (Js.Opt.test !innerView) then ( + _self##_open + ) + + method deselectNode = + El.set_class (Jstr.v "ProseMirror-selectednode") false _self##.dom; + if (Js.Opt.test !innerView) then + _self##close + + end diff --git a/editor/plugins/link_editor.ml b/editor/plugins/link_editor.ml new file mode 100755 index 0000000..9bfdfd4 --- /dev/null +++ b/editor/plugins/link_editor.ml @@ -0,0 +1,127 @@ +open Brr + +module Js = Js_of_ocaml.Js +module PM = Prosemirror + +let link_edit + : PM.View.editor_view Js.t -> < .. > Js.t + = fun view -> + + let popin = El.div [] + ~at:At.([class' (Jstr.v "popin")]) in + + El.set_inline_style El.Style.display (Jstr.v "none") popin; + + let parent = Jv.(Id.of_jv @@ get (Jv.Id.to_jv view##.dom) "parentNode") in + let () = El.append_children parent [popin] in + + let hide + : unit -> unit + = fun () -> + El.set_inline_style El.Style.display (Jstr.v "none") popin + in + + let update + : PM.View.editor_view Js.t -> PM.State.editor_state Js. t Js.opt -> unit + = fun view _state_opt -> + + let state = view##.state in + Js.Opt.case (state##.doc##nodeAt (view##.state##.selection##._to)) + (fun () -> hide ()) + (fun node -> + (* Check if we are editing a link *) + match PM.O.get state##.schema##.marks "link" with + | None -> () + | Some link_type -> + let is_present = link_type##isInSet node##.marks in + Js.Opt.case + is_present + (fun () -> hide ()) + (fun mark -> + (* Get the node's bounding position and display the popin *) + let position = state##.doc##resolve + (view##.state##.selection##._to) in + let start = position##start Js.null + and end' = position##_end Js.null in + + Popin.set_position + ~start + ~end' + view popin; + + (* Extract the value from the attribute *) + let attrs = mark##.attrs in + let href_opt = PM.O.get attrs "href" in + let href_value = Option.value + ~default:Jstr.empty + href_opt + in + + (* Create the popin content *) + let a = El.a + ~at:At.[ href href_value ] + [ El.txt href_value ] in + + let entry = Popin.build_field a + (fun new_value -> + (* The function is called when the user validate + the change in the popi. We create a new + transaction in the document by replacing the + mark with the new one. *) + if not (Jstr.equal new_value href_value) then ( + + (* Create a new attribute object for the mark in + order to keep history safe *) + let attrs' = PM.O.init + [| "href", new_value |] in + + Option.iter + (fun v -> PM.O.set attrs' "title" v) + (PM.O.get attrs "title"); + + let mark' = state##.schema##mark_fromType + link_type + (Js.some attrs') in + (* Create a transaction which update the + mark with the new value *) + view##dispatch + state + ##.tr + ##(removeMark + ~from:start + ~to_:end' + mark) + ##(addMark + ~from:start + ~to_:end' + mark') + ); + true + + ) in + + + El.set_children popin + [ entry.field + ; entry.button ]; + + )) + + and destroy () = El.remove popin in + + object%js + val update = Js.wrap_callback update + val destroy= Js.wrap_callback destroy + end + +let plugin + : PM.t -> PM.State.plugin Js.t + = fun t -> + let state = Jv.get (Jv.Id.to_jv t) "state" in + + let params = object%js + val view = (fun view -> link_edit view) + end in + + Jv.new' (Jv.get state "Plugin") [| Jv.Id.to_jv params |] + |> Jv.Id.of_jv diff --git a/editor/plugins/plugins.ml b/editor/plugins/plugins.ml new file mode 100755 index 0000000..3a92df8 --- /dev/null +++ b/editor/plugins/plugins.ml @@ -0,0 +1,137 @@ +module Js = Js_of_ocaml.Js +module PM = Prosemirror + +module Footnotes = Footnotes + +(** Commands *) + +let change_level + : PM.t -> PM.Model.resolved_pos Js.t -> int -> (int -> bool) -> PM.Commands.t + = fun pm res incr pred state dispatch -> + let parent = res##.parent in + let attributes = parent##.attrs in + + let current_level = if Jv.is_none attributes##.level then + 0 + else + attributes##.level in + let t, props = match pred current_level with + | false -> + ( PM.O.get state##.schema##.nodes "heading" + , Js.some (object%js + val level = current_level + incr + end)) + | true -> + ( PM.O.get state##.schema##.nodes "paragraph" + , Js.null) in + match t with + | None -> Js._false + | Some t -> + PM.Commands.set_block_type pm t props state dispatch + +(** Increase the title level by one when pressing # at the begining of a line *) +let handle_sharp pm state dispatch = + + let res = PM.State.selection_to (state##.selection) in + match Js.Opt.to_option res##.nodeBefore with + | Some _ -> Js._false + | None -> (* Line start *) + begin match Jstr.to_string res##.parent##._type##.name with + | "heading" -> + change_level pm res 1 (fun x -> x > 5) state dispatch + | "paragraph" -> + begin match PM.O.get state##.schema##.nodes "heading" with + | None -> Js._false + | Some t -> + let props = Js.some (object%js + val level = 1 + end) in + PM.Commands.set_block_type pm t props state dispatch + end + | _ -> Js._false + end + +let handle_backspace pm state dispatch = + + let res = PM.State.selection_to (state##.selection) in + match Js.Opt.to_option res##.nodeBefore with + | Some _ -> Js._false + | None -> (* Line start *) + begin match Jstr.to_string res##.parent##._type##.name with + | "heading" -> change_level pm res (-1) (fun x -> x <= 1) state dispatch + | _ -> Js._false + end + + +let toggle_mark + : Js.regExp Js.t -> PM.t -> string -> PM.InputRule.input_rule Js.t + = fun regExp pm mark_type_name -> + PM.InputRule.create pm + regExp + ~fn:(Js.wrap_callback @@ fun (state:PM.State.editor_state Js.t) _ ~from ~to_ -> + match PM.O.get state##.schema##.marks mark_type_name with + | None -> Js.null + | Some mark_type -> + + let m = state##.schema##mark_fromType mark_type Js.null in + + (* Delete the markup code *) + let tr = (state##.tr)##delete ~from ~to_ in + + (* Check if the mark is active at the position *) + let present = Js.Opt.bind + (PM.State.cursor (tr##.selection)) + (fun resolved -> + Js.Opt.map + (mark_type##isInSet (resolved##marks ())) + (fun _ -> resolved) + ) in + Js.Opt.case present + (fun () -> + let tr = tr##addStoredMark m in + Js.some @@ tr) + (fun _resolved -> + let tr = tr##removeStoredMark_mark m in + Js.some tr)) + +let input_rule pm = + + let bold = + toggle_mark + (new%js Js.regExp (Js.string "\\*\\*$")) + pm + "strong" + and em = + toggle_mark + (new%js Js.regExp (Js.string "//$")) + pm + "em" in + + PM.InputRule.to_plugin pm + (Js.array [| bold; em |]) + +let default pm schema = + + (** Load the history plugin *) + let _ = PM.History.(history pm (history_prop ()) ) in + + let props = PM.Example.options schema in + props##.menuBar := Js.some Js._true; + props##.floatingMenu := Js.some Js._true; + props##.menuContent := (Footnotes.build_menu pm schema)##.fullMenu; + let setup = PM.Example.example_setup pm props in + + let keymaps = + PM.Keymap.keymap pm + [| "Backspace", (handle_backspace pm) + ; "#", (handle_sharp pm) + |] in + + (* Add the custom keymaps in the list *) + let _ = setup##unshift keymaps in + let _ = setup##push (input_rule pm) in + let _ = setup##push (Tooltip.bold_plugin pm) in + let _ = setup##push (Link_editor.plugin pm) in + + + Js.some setup diff --git a/editor/plugins/popin.ml b/editor/plugins/popin.ml new file mode 100755 index 0000000..63dcad1 --- /dev/null +++ b/editor/plugins/popin.ml @@ -0,0 +1,83 @@ +open Brr +module Js = Js_of_ocaml.Js +module PM = Prosemirror + +type binded_field = + { field: El.t + ; button: El.t + } + +(** Set the element position just above the selection *) +let set_position + : start:int -> end':int -> PM.View.editor_view Js.t -> El.t -> unit + = fun ~start ~end' view el -> + El.set_inline_style El.Style.display (Jstr.v "") el; + + (* These are in screen coordinates *) + let start = view##coordsAtPos start Js.null + and end' = view##coordsAtPos end' Js.null in + let offsetParent = Jv.(Id.of_jv @@ get (Jv.Id.to_jv el) "offsetParent") in + + (* The box in which the tooltip is positioned, to use as base *) + let box = Jv.(Id.of_jv @@ call (Jv.Id.to_jv offsetParent) "getBoundingClientRect" [||]) in + let box_left = Jv.(Id.of_jv @@ get (Jv.Id.to_jv box) "left") in + let box_bottom = Jv.(Id.of_jv @@ get (Jv.Id.to_jv box) "bottom") in + + (* Find a center-ish x position from the selection endpoints (when + crossing lines, end may be more to the left) *) + let left = (start##.left +. end'##.left) /. 2. in + + El.set_inline_style (Jstr.v "left") + Jstr.( (of_float ( left -. box_left )) + (v "px") ) + el; + El.set_inline_style (Jstr.v "bottom") + Jstr.( (of_float ( box_bottom -. start##.top )) + (v "px") ) + el + +(** Build a button which allow to activate or desactivate the given Element. + + The function f is called when the user validate the input. + +*) +let build_field + : El.t -> (Jstr.t -> bool) -> binded_field + = fun field f -> + + let button_content = + [ El.i [] + ~at:At.[ class' (Jstr.v "fas") + ; class' (Jstr.v "fa-pen") ] + ] in + + let button = El.button + button_content + in + + Ev.listen Ev.click + (fun _ -> + match El.at (Jstr.v "contenteditable") field with + | Some value when (Jstr.equal value (Jstr.v "true")) -> + let new_value = El.prop + (El.Prop.jstr (Jstr.v "textContent")) + field in + begin match f new_value with + | true -> + El.set_at (Jstr.v "contenteditable") None field; + El.set_children button button_content + | false -> () + end + | _ -> + El.set_at (Jstr.v "contenteditable") + (Some (Jstr.v "true")) field; + El.set_children button + [ El.i + ~at:At.[ class' (Jstr.v "fas") + ; class' (Jstr.v "fa-check") ] + [] + ] + ) + (El.as_target button); + + { field + ; button = button + } diff --git a/editor/plugins/tooltip.ml b/editor/plugins/tooltip.ml new file mode 100755 index 0000000..05d56d4 --- /dev/null +++ b/editor/plugins/tooltip.ml @@ -0,0 +1,89 @@ +open StdLabels +open Brr + +module Js = Js_of_ocaml.Js +module PM = Prosemirror + +(** https://prosemirror.net/examples/tooltip/ *) + + +let boldtip + : PM.View.editor_view Js.t -> < .. > Js.t + = fun view -> + (* Create the element which will be displayed over the editor *) + let tooltip = El.div [] + ~at:At.([ class' (Jstr.v "popin") + ]) in + El.set_inline_style El.Style.display (Jstr.v "none") tooltip; + + let parent = Jv.(Id.of_jv @@ get (Jv.Id.to_jv view##.dom) "parentNode") in + let () = El.append_children parent [tooltip] in + + let update + : PM.View.editor_view Js.t -> PM.State.editor_state Js. t Js.opt -> unit + = fun view state_opt -> + + (* Compare the previous and actual state. If the stored marks are the + same, just return *) + let state = view##.state in + let previous_stored_marks = + Js.Opt.bind state_opt (fun state -> state##.storedMarks) + |> Js.Opt.to_option + and current_stored_marks = state##.storedMarks in + + let same = match previous_stored_marks, Js.Opt.to_option current_stored_marks with + | Some arr1, Some arr2 -> + Js_lib.Array.compare arr1 arr2 ~f:(fun v1 v2 -> v1##eq v2) + | None, None -> Js._true + | _, _ -> Js._false in + + if same <> Js._true then + + let is_bold = Option.bind (PM.O.get state##.schema##.marks "strong") + (fun mark_type -> + let is_strong = + Js.Opt.bind current_stored_marks + (fun t -> mark_type##isInSet t) in + Js.Opt.case is_strong + (fun () -> None) + (fun _ -> Some (Jstr.v "gras"))) in + let is_em = Option.bind (PM.O.get state##.schema##.marks "em") + (fun mark_type -> + let is_em = + Js.Opt.bind current_stored_marks + (fun t -> mark_type##isInSet t) in + Js.Opt.case is_em + (fun () -> None) + (fun _ -> Some (Jstr.(v "emphase")))) in + + let marks = List.filter_map [is_bold; is_em] ~f:(fun x -> x) in + match marks with + | [] -> El.set_inline_style El.Style.display (Jstr.v "none") tooltip + | _ -> + (* The mark is present, add in the content *) + let start = view##.state##.selection##.from + and end' = view##.state##.selection##._to in + Popin.set_position ~start ~end' view tooltip; + El.set_prop + (El.Prop.jstr (Jstr.v "textContent")) + (Jstr.concat marks ~sep:(Jstr.v ", ")) + tooltip + + and destroy () = El.remove tooltip in + + object%js + val update = Js.wrap_callback update + val destroy= Js.wrap_callback destroy + end + +let bold_plugin + : PM.t -> PM.State.plugin Js.t + = fun t -> + let state = Jv.get (Jv.Id.to_jv t) "state" in + + let params = object%js + val view = (fun view -> boldtip view) + end in + + Jv.new' (Jv.get state "Plugin") [| Jv.Id.to_jv params |] + |> Jv.Id.of_jv diff --git a/editor/popin.ml b/editor/popin.ml deleted file mode 100755 index 63dcad1..0000000 --- a/editor/popin.ml +++ /dev/null @@ -1,83 +0,0 @@ -open Brr -module Js = Js_of_ocaml.Js -module PM = Prosemirror - -type binded_field = - { field: El.t - ; button: El.t - } - -(** Set the element position just above the selection *) -let set_position - : start:int -> end':int -> PM.View.editor_view Js.t -> El.t -> unit - = fun ~start ~end' view el -> - El.set_inline_style El.Style.display (Jstr.v "") el; - - (* These are in screen coordinates *) - let start = view##coordsAtPos start Js.null - and end' = view##coordsAtPos end' Js.null in - let offsetParent = Jv.(Id.of_jv @@ get (Jv.Id.to_jv el) "offsetParent") in - - (* The box in which the tooltip is positioned, to use as base *) - let box = Jv.(Id.of_jv @@ call (Jv.Id.to_jv offsetParent) "getBoundingClientRect" [||]) in - let box_left = Jv.(Id.of_jv @@ get (Jv.Id.to_jv box) "left") in - let box_bottom = Jv.(Id.of_jv @@ get (Jv.Id.to_jv box) "bottom") in - - (* Find a center-ish x position from the selection endpoints (when - crossing lines, end may be more to the left) *) - let left = (start##.left +. end'##.left) /. 2. in - - El.set_inline_style (Jstr.v "left") - Jstr.( (of_float ( left -. box_left )) + (v "px") ) - el; - El.set_inline_style (Jstr.v "bottom") - Jstr.( (of_float ( box_bottom -. start##.top )) + (v "px") ) - el - -(** Build a button which allow to activate or desactivate the given Element. - - The function f is called when the user validate the input. - -*) -let build_field - : El.t -> (Jstr.t -> bool) -> binded_field - = fun field f -> - - let button_content = - [ El.i [] - ~at:At.[ class' (Jstr.v "fas") - ; class' (Jstr.v "fa-pen") ] - ] in - - let button = El.button - button_content - in - - Ev.listen Ev.click - (fun _ -> - match El.at (Jstr.v "contenteditable") field with - | Some value when (Jstr.equal value (Jstr.v "true")) -> - let new_value = El.prop - (El.Prop.jstr (Jstr.v "textContent")) - field in - begin match f new_value with - | true -> - El.set_at (Jstr.v "contenteditable") None field; - El.set_children button button_content - | false -> () - end - | _ -> - El.set_at (Jstr.v "contenteditable") - (Some (Jstr.v "true")) field; - El.set_children button - [ El.i - ~at:At.[ class' (Jstr.v "fas") - ; class' (Jstr.v "fa-check") ] - [] - ] - ) - (El.as_target button); - - { field - ; button = button - } diff --git a/editor/state/dune b/editor/state/dune new file mode 100755 index 0000000..dd405a1 --- /dev/null +++ b/editor/state/dune @@ -0,0 +1,9 @@ +(library + (name state) + (libraries + brr + prosemirror + plugins + ) + (preprocess (pps js_of_ocaml-ppx)) + ) diff --git a/editor/state/state.ml b/editor/state/state.ml new file mode 100755 index 0000000..48b4d58 --- /dev/null +++ b/editor/state/state.ml @@ -0,0 +1,70 @@ +open Brr +module PM = Prosemirror +module Js = Js_of_ocaml.Js + +module Storage = Storage + +(** This is the state for the application *) +type t = + { editable : bool + ; view : PM.View.editor_view Js.t + ; last_backup: float + ; page_id: Jstr.t option + + ; window : Brr.El.t list + ; pm : PM.t + } + +(** Compare two states together. + + The prosemirror elemens are ignored + +*) +let eq s1 s2 = + Stdlib.(==) + ( s1.editable + , s1.last_backup + , s1.page_id + , s1.window ) + + ( s2.editable + , s2.last_backup + , s2.page_id + , s2.window ) + +let set_title + : Storage.content Js.t -> unit + = fun content -> + let title = + Js.Opt.get + content##.title + (fun () -> Jstr.empty) in + let title_element = Document.find_el_by_id G.document (Jstr.v "title") in + Option.iter + (fun el -> El.set_prop (El.Prop.value) title el) + title_element + +let state_of_storage + : PM.t -> Storage.content Js.t -> PM.Model.schema Js.t -> PM.State.editor_state Js.t + = fun pm content schema -> + Js.Opt.case + content##.content + (fun () -> + let obj = PM.State.creation_prop () in + obj##.plugins := Plugins.default pm schema; + obj##.schema := Js.some schema; + PM.State.create pm obj) + (fun page_content -> + let obj = PM.State.configuration_prop () in + obj##.plugins := Plugins.default pm schema; + obj##.schema := Js.some schema; + PM.State.fromJSON pm obj page_content) + +let load_page + : Jstr.t option -> t -> Storage.content Js.t -> t + = fun page_id state json -> + let editor_state = state_of_storage state.pm json state.view##.state##.schema in + let () = state.view##updateState editor_state + and () = set_title json in + { state with page_id } + diff --git a/editor/state/state.mli b/editor/state/state.mli new file mode 100755 index 0000000..e370015 --- /dev/null +++ b/editor/state/state.mli @@ -0,0 +1,24 @@ +module Js = Js_of_ocaml.Js + +module Storage = Storage + +type t = + { editable : bool + ; view : Prosemirror.View.editor_view Js.t + ; last_backup: float + ; page_id: Jstr.t option + + ; window : Brr.El.t list + ; pm : Prosemirror.t + } + +val eq: t -> t -> bool + +val set_title + : Storage.content Js.t -> unit + +val state_of_storage + : Prosemirror.t -> Storage.content Js.t -> Prosemirror.Model.schema Js.t -> Prosemirror.State.editor_state Js.t + +val load_page + : Jstr.t option -> t -> Storage.content Js.t -> t diff --git a/editor/state/storage.ml b/editor/state/storage.ml new file mode 100755 index 0000000..f893c2d --- /dev/null +++ b/editor/state/storage.ml @@ -0,0 +1,137 @@ +open Brr +module Js = Js_of_ocaml.Js + +let storage_key = (Jstr.v "editor") + +let storage = Brr_io.Storage.local G.window + +class type content = object + + method title + : Jstr.t Js.opt Js.readonly_prop + + method content + : Jv.t Js.opt Js.readonly_prop + + method date + : float Js.opt Js.readonly_prop + +end + +let page_id + : unit -> Jstr.t option + = fun () -> + let uri = Brr.Window.location Brr.G.window in + let query = Brr.Uri.query uri in + let params = Brr.Uri.Params.of_jstr query in + Brr.Uri.Params.find (Jstr.v "page") params + +(** [load' pm schema content key] will load the content stored in the local + storage for the [key]. +*) +let load' + : Jstr.t -> content Js.t + = fun key -> + + let opt_data = Brr_io.Storage.get_item storage key in + match opt_data with + | None -> + object%js + val title = Js.null + val content = Js.null + val date = Js.null + end + | Some contents -> + + (* Try to load from the storage *) + match Json.decode contents with + | Error _ -> + object%js + val title = Js.null + val content = Js.null + val date = Js.null + end + + | Ok json -> + Jv.Id.of_jv json + +(** Save the view *) +let save' + : check:(content Js.t -> bool) -> content Js.t -> Jstr.t -> (bool, Jv.Error.t) result + = fun ~check object_content key -> + + (* First load the content from the storage *) + match check (load' key) with + | false -> Ok false + | true -> + let storage = Brr_io.Storage.local G.window in + let operation = Brr_io.Storage.set_item + storage + key + (Json.encode @@ Jv.Id.to_jv @@ object_content) in + Result.map (fun () -> true) operation + + +(** [load pm schema content f] will try load the content stored in the local + storage. The right key is given by the result of the function [f] +*) +let load + : Jstr.t option -> content Js.t + = fun key -> + match key with + | None -> load' storage_key + | Some value -> + let key = Jstr.concat + ~sep:(Jstr.v "_") + [storage_key ; value] in + load' key + +let save + : check:(content Js.t -> bool) -> content Js.t -> Jstr.t option -> (bool, Jv.Error.t) result + = fun ~check object_content key -> + match key with + | None -> + save' ~check object_content storage_key + | Some value -> + let key = Jstr.concat + ~sep:(Jstr.v "_") + [storage_key ; value] in + save' ~check object_content key + +let delete + : (unit -> Jstr.t option) -> unit + = fun f -> + match f () with + | None -> () + | Some value -> + let key = Jstr.concat + ~sep:(Jstr.v "_") + [storage_key ; value] in + let storage = Brr_io.Storage.local G.window in + Brr_io.Storage.remove_item storage key + +let get_ids + : unit -> Jstr.t list + = fun () -> + let open Brr_io in + let storage = Storage.local G.window in + let items = Storage.length storage in + + let sub = Jstr.( storage_key + (v "_") ) in + let start = Jstr.length sub in + + let rec add_element acc = function + | -1 -> acc + | nb -> + begin match Storage.key storage nb with + | Some key when (Jstr.starts_with ~sub key) -> + + let key_name = Jstr.sub key + ~start in + add_element (key_name::acc) (nb -1) + | _ -> + add_element acc (nb -1) + end + + in + add_element [] items diff --git a/editor/state/storage.mli b/editor/state/storage.mli new file mode 100755 index 0000000..5b7e0a0 --- /dev/null +++ b/editor/state/storage.mli @@ -0,0 +1,36 @@ +module Js = Js_of_ocaml.Js + +(** Provide a function for extracting the page id from the URL *) +val page_id + : unit -> Jstr.t option + +class type content = object + + method title + : Jstr.t Js.opt Js.readonly_prop + + method content + : Jv.t Js.opt Js.readonly_prop + + method date + : float Js.opt Js.readonly_prop + +end + +(** load f] will try to load the content associated with the given key. + + The function [f] is called to identified which is the appropriate page id. +*) +val load + : Jstr.t option -> content Js.t + +val save + : check:(content Js.t -> bool) -> content Js.t -> Jstr.t option -> (bool, Jv.Error.t) result + +(** Remove the page from the storage. *) +val delete + : (unit -> Jstr.t option) -> unit + +(** Collect all the keys to the existing pages *) +val get_ids + : unit -> Jstr.t list diff --git a/editor/storage.ml b/editor/storage.ml deleted file mode 100755 index f893c2d..0000000 --- a/editor/storage.ml +++ /dev/null @@ -1,137 +0,0 @@ -open Brr -module Js = Js_of_ocaml.Js - -let storage_key = (Jstr.v "editor") - -let storage = Brr_io.Storage.local G.window - -class type content = object - - method title - : Jstr.t Js.opt Js.readonly_prop - - method content - : Jv.t Js.opt Js.readonly_prop - - method date - : float Js.opt Js.readonly_prop - -end - -let page_id - : unit -> Jstr.t option - = fun () -> - let uri = Brr.Window.location Brr.G.window in - let query = Brr.Uri.query uri in - let params = Brr.Uri.Params.of_jstr query in - Brr.Uri.Params.find (Jstr.v "page") params - -(** [load' pm schema content key] will load the content stored in the local - storage for the [key]. -*) -let load' - : Jstr.t -> content Js.t - = fun key -> - - let opt_data = Brr_io.Storage.get_item storage key in - match opt_data with - | None -> - object%js - val title = Js.null - val content = Js.null - val date = Js.null - end - | Some contents -> - - (* Try to load from the storage *) - match Json.decode contents with - | Error _ -> - object%js - val title = Js.null - val content = Js.null - val date = Js.null - end - - | Ok json -> - Jv.Id.of_jv json - -(** Save the view *) -let save' - : check:(content Js.t -> bool) -> content Js.t -> Jstr.t -> (bool, Jv.Error.t) result - = fun ~check object_content key -> - - (* First load the content from the storage *) - match check (load' key) with - | false -> Ok false - | true -> - let storage = Brr_io.Storage.local G.window in - let operation = Brr_io.Storage.set_item - storage - key - (Json.encode @@ Jv.Id.to_jv @@ object_content) in - Result.map (fun () -> true) operation - - -(** [load pm schema content f] will try load the content stored in the local - storage. The right key is given by the result of the function [f] -*) -let load - : Jstr.t option -> content Js.t - = fun key -> - match key with - | None -> load' storage_key - | Some value -> - let key = Jstr.concat - ~sep:(Jstr.v "_") - [storage_key ; value] in - load' key - -let save - : check:(content Js.t -> bool) -> content Js.t -> Jstr.t option -> (bool, Jv.Error.t) result - = fun ~check object_content key -> - match key with - | None -> - save' ~check object_content storage_key - | Some value -> - let key = Jstr.concat - ~sep:(Jstr.v "_") - [storage_key ; value] in - save' ~check object_content key - -let delete - : (unit -> Jstr.t option) -> unit - = fun f -> - match f () with - | None -> () - | Some value -> - let key = Jstr.concat - ~sep:(Jstr.v "_") - [storage_key ; value] in - let storage = Brr_io.Storage.local G.window in - Brr_io.Storage.remove_item storage key - -let get_ids - : unit -> Jstr.t list - = fun () -> - let open Brr_io in - let storage = Storage.local G.window in - let items = Storage.length storage in - - let sub = Jstr.( storage_key + (v "_") ) in - let start = Jstr.length sub in - - let rec add_element acc = function - | -1 -> acc - | nb -> - begin match Storage.key storage nb with - | Some key when (Jstr.starts_with ~sub key) -> - - let key_name = Jstr.sub key - ~start in - add_element (key_name::acc) (nb -1) - | _ -> - add_element acc (nb -1) - end - - in - add_element [] items diff --git a/editor/storage.mli b/editor/storage.mli deleted file mode 100755 index 5b7e0a0..0000000 --- a/editor/storage.mli +++ /dev/null @@ -1,36 +0,0 @@ -module Js = Js_of_ocaml.Js - -(** Provide a function for extracting the page id from the URL *) -val page_id - : unit -> Jstr.t option - -class type content = object - - method title - : Jstr.t Js.opt Js.readonly_prop - - method content - : Jv.t Js.opt Js.readonly_prop - - method date - : float Js.opt Js.readonly_prop - -end - -(** load f] will try to load the content associated with the given key. - - The function [f] is called to identified which is the appropriate page id. -*) -val load - : Jstr.t option -> content Js.t - -val save - : check:(content Js.t -> bool) -> content Js.t -> Jstr.t option -> (bool, Jv.Error.t) result - -(** Remove the page from the storage. *) -val delete - : (unit -> Jstr.t option) -> unit - -(** Collect all the keys to the existing pages *) -val get_ids - : unit -> Jstr.t list diff --git a/editor/tooltip.ml b/editor/tooltip.ml deleted file mode 100755 index 05d56d4..0000000 --- a/editor/tooltip.ml +++ /dev/null @@ -1,89 +0,0 @@ -open StdLabels -open Brr - -module Js = Js_of_ocaml.Js -module PM = Prosemirror - -(** https://prosemirror.net/examples/tooltip/ *) - - -let boldtip - : PM.View.editor_view Js.t -> < .. > Js.t - = fun view -> - (* Create the element which will be displayed over the editor *) - let tooltip = El.div [] - ~at:At.([ class' (Jstr.v "popin") - ]) in - El.set_inline_style El.Style.display (Jstr.v "none") tooltip; - - let parent = Jv.(Id.of_jv @@ get (Jv.Id.to_jv view##.dom) "parentNode") in - let () = El.append_children parent [tooltip] in - - let update - : PM.View.editor_view Js.t -> PM.State.editor_state Js. t Js.opt -> unit - = fun view state_opt -> - - (* Compare the previous and actual state. If the stored marks are the - same, just return *) - let state = view##.state in - let previous_stored_marks = - Js.Opt.bind state_opt (fun state -> state##.storedMarks) - |> Js.Opt.to_option - and current_stored_marks = state##.storedMarks in - - let same = match previous_stored_marks, Js.Opt.to_option current_stored_marks with - | Some arr1, Some arr2 -> - Js_lib.Array.compare arr1 arr2 ~f:(fun v1 v2 -> v1##eq v2) - | None, None -> Js._true - | _, _ -> Js._false in - - if same <> Js._true then - - let is_bold = Option.bind (PM.O.get state##.schema##.marks "strong") - (fun mark_type -> - let is_strong = - Js.Opt.bind current_stored_marks - (fun t -> mark_type##isInSet t) in - Js.Opt.case is_strong - (fun () -> None) - (fun _ -> Some (Jstr.v "gras"))) in - let is_em = Option.bind (PM.O.get state##.schema##.marks "em") - (fun mark_type -> - let is_em = - Js.Opt.bind current_stored_marks - (fun t -> mark_type##isInSet t) in - Js.Opt.case is_em - (fun () -> None) - (fun _ -> Some (Jstr.(v "emphase")))) in - - let marks = List.filter_map [is_bold; is_em] ~f:(fun x -> x) in - match marks with - | [] -> El.set_inline_style El.Style.display (Jstr.v "none") tooltip - | _ -> - (* The mark is present, add in the content *) - let start = view##.state##.selection##.from - and end' = view##.state##.selection##._to in - Popin.set_position ~start ~end' view tooltip; - El.set_prop - (El.Prop.jstr (Jstr.v "textContent")) - (Jstr.concat marks ~sep:(Jstr.v ", ")) - tooltip - - and destroy () = El.remove tooltip in - - object%js - val update = Js.wrap_callback update - val destroy= Js.wrap_callback destroy - end - -let bold_plugin - : PM.t -> PM.State.plugin Js.t - = fun t -> - let state = Jv.get (Jv.Id.to_jv t) "state" in - - let params = object%js - val view = (fun view -> boldtip view) - end in - - Jv.new' (Jv.get state "Plugin") [| Jv.Id.to_jv params |] - |> Jv.Id.of_jv -- cgit v1.2.3