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}