package quill

  1. Overview
  2. Docs

Source file dom_utils.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
open Brr
open Brr_ext

let log fmt =
  Printf.ksprintf (fun s -> Console.(log [ Jstr.v ("[dom_utils] " ^ s) ])) fmt

let node_list_to_list (nl : Jv.t) : El.t list =
  let len = Jv.Int.get nl "length" in
  let rec loop i acc =
    if i >= len then List.rev acc
    else
      let item = Jv.call nl "item" [| Jv.of_int i |] in
      if Jv.is_null item then loop (i + 1) acc
      else loop (i + 1) (El.of_jv item :: acc)
  in
  loop 0 []

let get_element_inline_id (el : El.t) : int option =
  let id_str = El.prop El.Prop.id el |> Jstr.to_string in
  match String.split_on_char '-' id_str with
  | [ _type; id_num ] -> int_of_string_opt id_num
  | _ -> None

let get_current_range () : Range.t option =
  match Window.get_selection G.window with
  | None -> None
  | Some sel ->
      if Selection.range_count sel > 0 then Some (Selection.get_range_at sel 0)
      else None

let parse_id_from_string ~(prefix : string) (id_str : string) : int option =
  if String.starts_with ~prefix id_str then
    let id_part =
      String.sub id_str (String.length prefix)
        (String.length id_str - String.length prefix)
    in
    try Some (int_of_string id_part) with Failure _ -> None
  else None

let rec find_ancestor (predicate : El.t -> bool) ?(stop_at : El.t option)
    (node : El.t) : El.t option =
  let check_stop current =
    match stop_at with
    | Some stop_el when Jv.equal (El.to_jv current) (El.to_jv stop_el) -> true
    | _ -> false
  in
  if check_stop node then None
  else if predicate node then Some node
  else
    match El.parent node with
    | Some parent -> find_ancestor predicate ?stop_at parent
    | None -> None

let get_element_id_with_prefix (prefix : string) (el : El.t) : int option =
  El.prop El.Prop.id el |> Jstr.to_string |> parse_id_from_string ~prefix

let get_element_block_id el = get_element_id_with_prefix "block-" el
let get_element_run_id el = get_element_id_with_prefix "run-" el
let get_element_codeblock_id el = get_element_id_with_prefix "codeblock-" el

let has_prefix_id prefix el =
  get_element_id_with_prefix prefix el |> Option.is_some

let find_block_parent ?stop_at node =
  find_ancestor (has_prefix_id "block-") ?stop_at node

let find_run_ancestor node = find_ancestor (has_prefix_id "run-") node

let get_element_tag node =
  El.tag_name node |> Jstr.to_string |> String.uppercase_ascii

let find_codeblock_ancestor node =
  find_ancestor
    (fun el -> get_element_tag el = "CODE" && has_prefix_id "codeblock-" el)
    node

let has_class (el : El.t) class_name : bool =
  let class_list = El.prop (El.Prop.jstr (Jstr.of_string "class")) el in
  Jstr.find_sub ~sub:(Jstr.v class_name) class_list |> Option.is_some

let is_inline_id id_str =
  List.exists
    (fun prefix -> String.starts_with ~prefix id_str)
    [ "run-"; "codespan-"; "emph-"; "strong-"; "seq-" ]

let find_inline_ancestor node =
  find_ancestor
    (fun el ->
      let id_str = El.prop El.Prop.id el |> Jstr.to_string in
      is_inline_id id_str || has_class el "inline-text")
    node

let contains_range_start (range : Range.t) (node : El.t) : bool =
  let container_jv = Range.start_container range in
  let target_jv = El.to_jv node in
  let rec is_descendant current_jv =
    if Jv.equal current_jv target_jv then true
    else
      match Jv.find current_jv "parentNode" with
      | Some parent_jv
        when not (Jv.is_null parent_jv || Jv.is_undefined parent_jv) ->
          is_descendant parent_jv
      | _ -> false
  in
  if Jv.is_null container_jv || Jv.is_undefined container_jv then false
  else is_descendant container_jv

let get_node_value node : string =
  Jv.find (El.to_jv node) "nodeValue"
  |> Option.map Jv.to_jstr
  |> Option.value ~default:Jstr.empty
  |> Jstr.to_string

let get_text_content node : string =
  if Jv.get (El.to_jv node) "nodeType" |> Jv.to_int = 3 then get_node_value node
  else if El.is_el node then El.text_content node |> Jstr.to_string
  else ""

let inner_text node : string =
  if El.is_el node then Brr_ext.El.inner_text node |> Jstr.to_string else ""

let is_span_with_text node text =
  if El.is_el node then get_element_tag node = "SPAN" && inner_text node = text
  else false

let rec find_first_text_node (node : El.t) : El.t option =
  if El.is_txt node then Some node
  else
    let children = El.children node in
    let rec search = function
      | [] -> None
      | child :: rest -> (
          match find_first_text_node child with
          | Some text -> Some text
          | None -> search rest)
    in
    search children

let find_next_block_element (el : El.t) : El.t option =
  let rec find_next sibling_opt =
    match sibling_opt with
    | None -> None
    | Some sibling ->
        if El.is_el sibling && get_element_block_id sibling |> Option.is_some
        then Some sibling
        else find_next (El.next_sibling sibling)
  in
  find_next (El.next_sibling el)

let focus_element_start (el : El.t) : unit =
  match El.find_first_by_selector ~root:el (Jstr.v ".inline-text") with
  | Some text_node -> (
      match Window.get_selection G.window with
      | Some sel ->
          let range = Document.create_range G.document in
          Range.set_start range (El.to_jv text_node) 0;
          Range.collapse range true;
          Selection.remove_all_ranges sel;
          Selection.add_range sel range;
          El.scroll_into_view el
      | None -> log "focus_element_start: No selection object")
  | None -> log "focus_element_start: No .inline-text node found in element"

let get_caret_offset_within (element : El.t) : int =
  match Window.get_selection G.window with
  | None -> 0
  | Some sel ->
      if Selection.range_count sel > 0 then (
        let range = Selection.get_range_at sel 0 in
        let pre_caret_range = Range.clone range in
        Range.select_node_contents pre_caret_range (El.to_jv element);
        Range.set_end pre_caret_range
          (Range.end_container range)
          (Range.end_offset range);
        let text = Range.to_string pre_caret_range in
        Jstr.length text)
      else 0

let get_selection_offsets_within (element : El.t) : int * int =
  match Window.get_selection G.window with
  | None -> (0, 0)
  | Some sel ->
      if Selection.range_count sel > 0 then (
        let range = Selection.get_range_at sel 0 in
        (* Calculate start offset *)
        let start_range = Document.create_range G.document in
        Range.select_node_contents start_range (El.to_jv element);
        Range.set_end start_range
          (Range.start_container range)
          (Range.start_offset range);
        let start_offset = Jstr.length (Range.to_string start_range) in
        (* Calculate end offset *)
        let end_range = Document.create_range G.document in
        Range.select_node_contents end_range (El.to_jv element);
        Range.set_end end_range
          (Range.end_container range)
          (Range.end_offset range);
        let end_offset = Jstr.length (Range.to_string end_range) in
        (start_offset, end_offset))
      else (0, 0)

(* Type to represent the result of DOM traversal *)
type traverse_result =
  | Found of { node : El.t; position : int }
  | Not_found of int

(* Recursively traverse the DOM to find the text node at a given offset *)
let rec traverse (node : El.t) (offset : int) : traverse_result =
  if El.is_txt node then
    let text = El.text_content node in
    let length = Jstr.length text in
    if offset <= length then Found { node; position = offset }
    else Not_found length
  else
    let child_nodes = Jv.get (El.to_jv node) "childNodes" in
    let children = node_list_to_list child_nodes in
    let rec loop cumulative children =
      match children with
      | [] -> Not_found cumulative
      | child :: rest -> (
          match traverse child (offset - cumulative) with
          | Found res -> Found res
          | Not_found len -> loop (cumulative + len) rest)
    in
    loop 0 children

(* Find the text node and position for a given character index *)
let get_text_node_at_position (root : El.t) (index : int) : El.t * int =
  match traverse root index with
  | Found { node; position } -> (node, position)
  | Not_found _ -> (root, index)

let set_caret_offset_within (context : El.t) (offset : int) : unit =
  match Window.get_selection G.window with
  | None -> ()
  | Some sel ->
      log "Restoring caret position: %d" offset;
      let node, position = get_text_node_at_position context offset in
      let new_range = Document.create_range G.document in
      Range.set_start new_range (El.to_jv node) position;
      Range.set_end new_range (El.to_jv node) position;
      Selection.remove_all_ranges sel;
      Selection.add_range sel new_range
OCaml

Innovation. Community. Security.