FloatingMenu.res

  1open Preact
  2
  3let show = Signal.make(false)
  4let focusedIndex = Signal.make(0)
  5
  6type t = {
  7  mutable timeout: nullable<Window.timeoutId>,
  8  mutable editor: nullable<Editor.t>,
  9}
 10
 11let state = {timeout: Nullable.null, editor: Nullable.null}
 12
 13let closeMenu = () => {
 14  switch state.timeout->Nullable.toOption {
 15  | Some(id) => Window.clearTimeout(id)
 16  | None => ()
 17  }
 18  state.timeout = Nullable.make(Window.setTimeout(() => show->Signal.set(false), 500))
 19}
 20
 21let stopClose = () => {
 22  switch state.timeout->Nullable.toOption {
 23  | Some(id) => Window.clearTimeout(id)
 24  | None => ()
 25  }
 26}
 27
 28let setEditor = (e: Editor.t) => {
 29  state.editor = Nullable.make(e)
 30  e->Tiptap.isFocused || show->Signal.get
 31}
 32
 33let do = (callback: Editor.t => unit) => {
 34  switch state.editor->Nullable.toOption {
 35  | Some(e) => callback(e)
 36  | None => ()
 37  }
 38  show->Signal.set(false)
 39}
 40
 41let content = (c: {..}) => {
 42  switch state.editor->Nullable.toOption {
 43  | Some(e) => e->Tiptap.Commands.insertContent(c->Obj.magic)
 44  | None => ()
 45  }
 46  show->Signal.set(false)
 47}
 48
 49let html = (h: string) => {
 50  switch state.editor->Nullable.toOption {
 51  | Some(e) => e->Tiptap.Commands.insertContent(h->Obj.magic)
 52  | None => ()
 53  }
 54  show->Signal.set(false)
 55}
 56
 57let selectFn = sig =>
 58  (result: Waldo.result) => {
 59    let content1 = Obj.magic({
 60      "type": "waypoint",
 61      "attrs": {
 62        "id": NanoID.make(),
 63        "point": (result.lon, result.lat),
 64        "label": result.name,
 65        "country": result.country,
 66        "gid": Int.toString(result.gid),
 67        "from": None,
 68        "cons": [],
 69        "nonroutable": false,
 70        "error": None,
 71      },
 72    })
 73
 74    let content2 = Obj.magic({"type": "text", "content": " "})
 75    switch state.editor->Nullable.toOption {
 76    | Some(editor) => {
 77        editor->Tiptap.Commands.insertContent(content1)
 78        editor->Tiptap.Commands.insertContent(content2)
 79      }
 80    | None => ()
 81    }
 82    show->Preact.Signal.set(false)
 83    sig->Preact.Signal.set(None)
 84  }
 85
 86let selectWaypoint = (docID: string) => {
 87  switch Editor.onSearchSelectCache->Map.get(docID) {
 88  | Some(sig) => sig->Preact.Signal.set(Some(selectFn(sig)))
 89  | None => ()
 90  }
 91}
 92
 93let waypointClickHandler = (docID: string) =>
 94  _evt => {
 95    selectWaypoint(docID)
 96  }
 97
 98@jsx.component
 99let make = (~doc: string) => {
100  let baseBtn = "py-3 md:py-1 w-full text-lg md:text-sm hover:cursor-pointer flex flex-row items-center"
101  let highlighted = " bg-[var(--color-blue-100)]"
102  let containerRef = Preact.useRef(Nullable.null)
103  let menuRef = Preact.useRef(Nullable.null)
104
105  let item0Id = "floating-menu-waypoint-" ++ doc
106  let item1Id = "floating-menu-image-" ++ doc
107  let item2Id = "floating-menu-video-" ++ doc
108
109  let getItemId = (index: int) => {
110    switch index {
111    | 0 => item0Id
112    | 1 => item1Id
113    | 2 => item2Id
114    | _ => item0Id
115    }
116  }
117
118  // Focus the menu container when it opens
119  Preact.Signal.effect(() => {
120    if show->Signal.get {
121      switch menuRef.current->Nullable.toOption {
122      | Some(el) => el->PreactDOM.focus()
123      | None => ()
124      }
125    }
126  })
127
128  // Reset focusedIndex when menu closes
129  Preact.Signal.effect(() => {
130    if !(show->Signal.get) {
131      focusedIndex->Signal.set(0)
132    }
133  })
134
135  // Global keyboard shortcut: Cmd+Shift+P opens menu
136  Preact.useEffect(() => {
137    let handleKeyDown = (evt: PreactDOM.KeyboardEvent.t) => {
138      if (
139        evt->PreactDOM.KeyboardEvent.metaKey &&
140        evt->PreactDOM.KeyboardEvent.shiftKey &&
141        evt->PreactDOM.KeyboardEvent.key == "P"
142      ) {
143        evt->PreactDOM.KeyboardEvent.preventDefault
144        if !(show->Signal.get) {
145          show->Signal.set(true)
146        }
147      }
148    }
149    let documentElement = Obj.magic(WebAPI.Global.document)
150    let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown)
151    Some(() => cleanup())
152  }, [])
153
154  let isOpen = show->Signal.get
155
156  // Keyboard navigation when menu is open
157  Preact.useEffect1(() => {
158    if isOpen {
159      let handleKeyDown = (evt: PreactDOM.KeyboardEvent.t) => {
160        let key = evt->PreactDOM.KeyboardEvent.key
161        let shiftKey = evt->PreactDOM.KeyboardEvent.shiftKey
162        let current = focusedIndex->Signal.get
163
164        switch key {
165        | "Escape" => {
166            evt->PreactDOM.KeyboardEvent.preventDefault
167            show->Signal.set(false)
168            switch state.editor->Nullable.toOption {
169            | Some(e) => e->Tiptap.Commands.focus
170            | None => ()
171            }
172          }
173        | "ArrowDown" | "Tab" =>
174          if key == "Tab" && shiftKey {
175            () // Shift+Tab handled below
176          } else {
177            evt->PreactDOM.KeyboardEvent.preventDefault
178            let next = if current < 2 {
179              current + 1
180            } else {
181              0
182            }
183            focusedIndex->Signal.set(next)
184          }
185        | "ArrowUp" => {
186            evt->PreactDOM.KeyboardEvent.preventDefault
187            let prev = if current > 0 {
188              current - 1
189            } else {
190              2
191            }
192            focusedIndex->Signal.set(prev)
193          }
194        | "Enter" =>
195          if current == 0 {
196            selectWaypoint(doc)
197          }
198        // Enter on disabled items (1, 2) is a no-op
199        | _ => ()
200        }
201
202        if key == "Tab" && shiftKey {
203          evt->PreactDOM.KeyboardEvent.preventDefault
204          let prev = if current > 0 {
205            current - 1
206          } else {
207            2
208          }
209          focusedIndex->Signal.set(prev)
210        }
211      }
212
213      let documentElement = Obj.magic(WebAPI.Global.document)
214      let cleanup = PreactDOM.addEventListener(documentElement, "keydown", handleKeyDown)
215      Some(() => cleanup())
216    } else {
217      None
218    }
219  }, [isOpen])
220
221  // Outside click closes menu
222  Preact.useEffect1(() => {
223    if isOpen {
224      let handleMouseDown = (evt: PreactDOM.MouseEvent.t) => {
225        let target = evt->PreactDOM.MouseEvent.target
226        switch containerRef.current->Nullable.toOption {
227        | Some(container) => {
228            let isInside = (container->Obj.magic)["contains"](target)
229            if !isInside {
230              show->Signal.set(false)
231              switch state.editor->Nullable.toOption {
232              | Some(e) => e->Tiptap.Commands.focus
233              | None => ()
234              }
235            }
236          }
237        | None => ()
238        }
239      }
240      let documentElement = Obj.magic(WebAPI.Global.document)
241      let cleanup = PreactDOM.addEventListener(documentElement, "mousedown", handleMouseDown)
242      Some(() => cleanup())
243    } else {
244      None
245    }
246  }, [isOpen])
247
248  let index = focusedIndex->Signal.get
249
250  <Editor.FloatingMenu
251    shouldShow={props => setEditor(props.editor)} options={{placement: #"left-start"}}
252  >
253    <div
254      ref={PreactDOM.Ref.domRef(containerRef)}
255      className={cls(
256        "fixed left-0 box-border flex flex-row items-center",
257        dict{
258          "shadow-xl/20 bg-[var(--color-paper)] ring-2 p-[2px] ring-[var(--color-blue)] rounded-sm ml-2": show->Signal.get,
259        },
260      )}
261    >
262      <button
263        type_="button"
264        className="hover:cursor-pointer hover:bg-[var(--color-bg-2)] rounded-sm text-[var(--color-blue)] my-auto"
265        onClick={_evt => show->Signal.set(!(show->Signal.get))}
266        ariaHaspopup={#menu}
267        ariaExpanded={show->Signal.get}
268      >
269        <Icons.GripVertical className="w-5" />
270      </button>
271
272      {show->Signal.get
273        ? <div
274            ref={PreactDOM.Ref.domRef(menuRef)}
275            className="flex flex-col active:outline-0 focus:outline-0"
276            onMouseLeave={_evt => closeMenu()}
277            onMouseEnter={_evt => stopClose()}
278            role="menu"
279            tabIndex={0}
280            ariaActivedescendant={getItemId(index)}
281          >
282            <button
283              id={item0Id}
284              className={baseBtn ++ (index === 0 ? highlighted : " bg-[var(--color-paper)]")}
285              onClick={waypointClickHandler(doc)}
286              onMouseEnter={_evt => focusedIndex->Signal.set(0)}
287              role="menuitem"
288              tabIndex={-1}
289            >
290              <Icons.Waypoint className="p-[2px]" />
291              <span className="ml-2"> {"Add waypoint"->Preact.string} </span>
292            </button>
293
294            <button
295              id={item1Id}
296              className={baseBtn ++ (index === 1 ? highlighted : " bg-[var(--color-paper)]")}
297              disabled={true}
298              onMouseEnter={_evt => focusedIndex->Signal.set(1)}
299              role="menuitem"
300              ariaDisabled={true}
301              tabIndex={-1}
302            >
303              <Icons.Image className="p-[2px]" />
304              <span className="ml-2"> {"Upload image"->Preact.string} </span>
305              <span className="italic ml-2 text-xs"> {" (soon!)"->Preact.string} </span>
306            </button>
307
308            <button
309              id={item2Id}
310              className={baseBtn ++ (index === 2 ? highlighted : " bg-[var(--color-paper)]")}
311              disabled={true}
312              onMouseEnter={_evt => focusedIndex->Signal.set(2)}
313              role="menuitem"
314              ariaDisabled={true}
315              tabIndex={-1}
316            >
317              <Icons.Video className="p-[2px]" />
318              <span className="ml-2"> {"Link YT Video"->Preact.string} </span>
319              <span className="italic ml-2 text-xs"> {" (soon!)"->Preact.string} </span>
320            </button>
321          </div>
322        : Preact.null}
323    </div>
324  </Editor.FloatingMenu>
325}