Editor.res
1// src/ui/Editor.res
2// Preact-compatible FFI bindings for @tiptap/react composable API
3
4// Re-export editor type for idiomatic Rescript usage
5type t = Tiptap.t
6
7// FloatingUI module - groups all Floating UI related types
8module FloatingUI = {
9 // Positioning strategy
10 // absolute (default): positioned relative to nearest positioned ancestor
11 // fixed: positioned relative to viewport (better for scrolling contexts)
12 type strategy = [#absolute | #fixed]
13
14 // Placement variants for menu positioning
15 // Covers 95% of use cases for positioning menus relative to selection
16 type placement = [
17 | #top
18 | #bottom
19 | #left
20 | #right
21 | #"top-start"
22 | #"top-end"
23 | #"bottom-start"
24 | #"bottom-end"
25 | #"left-start"
26 | #"left-end"
27 | #"right-start"
28 | #"right-end"
29 ]
30
31 // Offset can be simple pixels or detailed axes
32 // Pixels: simple number for main axis offset
33 // Axes: object with mainAxis and crossAxis for fine control
34 type offset =
35 | Pixels(int)
36 | Axes({mainAxis?: int, crossAxis?: int})
37
38 // Simplified Floating UI options for menu configuration
39 // Includes most common options without requiring Floating UI docs lookup
40 type options = {
41 strategy?: strategy,
42 placement?: placement,
43 offset?: offset,
44 // Callbacks for menu lifecycle
45 onShow?: unit => unit,
46 onHide?: unit => unit,
47 onUpdate?: unit => unit,
48 }
49}
50
51// Props passed to shouldShow callback for BubbleMenu
52type bubbleShouldShowProps = {
53 editor: Tiptap.t,
54 view: unknown,
55 state: unknown,
56 oldState: unknown,
57 from: int,
58 to: int,
59}
60
61// Props passed to shouldShow callback for FloatingMenu
62type floatingShouldShowProps = {
63 editor: Tiptap.t,
64 view: unknown,
65 state: unknown,
66 oldState: unknown,
67}
68
69module Instance = {
70 // Main TipTap component - provides editor context to all children
71 // instance is REQUIRED to ensure users create the editor before rendering
72 @module("@tiptap/react") @jsx.component
73 external make: (
74 ~instance: Tiptap.t,
75 ~id: string=?,
76 ~ref: PreactDOM.Ref.t=?,
77 ~className: string=?,
78 ~children: Jsx.element=?,
79 ) => Jsx.element = "Tiptap"
80}
81
82// Cache for onSearchSelect signals, recalled from cache if component is unmounted/remounted
83let onSearchSelectCache = Map.make()
84
85/** Editor component wraps an Editor.Instance, creating the TipTap Editor class or recalling it from cache
86* to maintain internal state. Classnames are passed through to the underlying Editor.Instance
87* hidden, disabled states are signals to prevent rerender of the component on change */
88@jsx.component
89let make = (
90 ~doc: string,
91 ~children: Jsx.element,
92 ~disabled: option<Preact.Signal.t<bool>>=?,
93 ~hidden: option<Preact.Signal.t<bool>>=?,
94 ~className: option<string>=?,
95 ~_focusPoint: option<(float, float, float)>=?,
96) => {
97 // lon, lat, radius
98 let classes = ["w-full", "px-3", "pb-[50vh]", "md:pb-14"]
99 let editor = switch State.Editor.cache->Map.get(doc) {
100 | Some(instance) => instance
101 | None => {
102 // Get or create Yjs doc from State module
103 // This handles doc creation and observer setup with proper lifetime
104 let ydoc = State.getOrCreateYjsDoc(doc)
105
106 // Create editor with onUpdate callback
107 // We need a mutable reference because onUpdate references the editor
108 let editorRef: ref<option<Tiptap.t>> = ref(None)
109
110 let instance = Tiptap.make({
111 editorProps: {
112 attributes: {class: classes->Array.join(" ")},
113 scrollMargin: {bottom: 48},
114 },
115 extensions: Tiptap.extensions(ydoc, doc),
116 onUpdate: _event => {
117 // Trigger debounced waypoint extraction
118 switch editorRef.contents {
119 | Some(ed) => {
120 State.WaypointUpdater.trigger(doc, ed)
121 Console.log(ed->Tiptap.getJSON)
122 }
123 | None => ()
124 }
125 },
126 })
127
128 // Store reference for onUpdate callback
129 editorRef := Some(instance)
130
131 instance->Tiptap.Commands.setMeta("id", doc)
132 State.Editor.cache->Map.set(doc, instance)
133
134 // Trigger immediate waypoint extraction for initial state
135 State.WaypointUpdater.trigger(doc, instance)
136
137 instance
138 }
139 }
140 Preact.Signal.effect(() =>
141 switch disabled {
142 | Some(sig) => {
143 editor->Tiptap.setEditable(!(sig->Preact.Signal.get))
144 if sig->Preact.Signal.get {
145 Log.debug("editor disabled: blur")
146 editor->Tiptap.Commands.blur
147 } else {
148 Log.debug("editor enabled: focus")
149 editor->Tiptap.Commands.focus
150 }
151 }
152 | None => ()
153 }
154 )
155 let clsName = switch hidden {
156 | Some(sig) => Preact.cls("w-full h-full px-2 mb-6", dict{"hidden": sig->Preact.Signal.get})
157 | None => "w-full h-full px-2"
158 }
159 // Waypoint search handler - attaches to document based on onSearchSelect state
160 // CRITICAL: Signal must be retrieved from cache (not recreated) to persist across re-renders
161 let onSearchSelect = switch onSearchSelectCache->Map.get(doc) {
162 | Some(sig) => sig
163 | None => {
164 let searchSelect = Preact.Signal.make(None)
165 onSearchSelectCache->Map.set(doc, searchSelect)
166 searchSelect
167 }
168 }
169 let showSearch = Preact.Signal.computed(() => onSearchSelect->Preact.Signal.get->Option.isSome)
170
171 // Document-level event listener management:
172 // - When search is closed (None): attach listener to detect @ triggers
173 // - When search is open (Some): remove listener (event is a no-op while searching)
174 // - Cleanup runs automatically when effect re-runs (signal changes or unmount)
175 Preact.Signal.effectWithCleanup(() => {
176 switch onSearchSelect->Preact.Signal.get {
177 | Some(_) =>
178 // Search is open - listener should be removed
179 // Previous cleanup already ran, just return None to indicate no new listener
180 None
181 | None => {
182 // Search is closed - attach listener to document
183 let handleWaypointSearch = (event: Waypoint.waypointSearchEvent) => {
184 // Double-check state in handler (guard against race conditions)
185 switch onSearchSelect->Preact.Signal.get {
186 | Some(_) => () // search already open, ignoring event (no-op)
187 | None => {
188 // got waypointsearch event, open search
189 let selectFn = (result: Waldo.result) => {
190 switch event.range {
191 | Some(range) => {
192 // Build waypoint attributes from Waldo result
193 let attrs: Waypoint.attributes = {
194 id: NanoID.make(),
195 point: (result.lon, result.lat),
196 from: None,
197 label: result.name,
198 gid: Int.toString(result.gid),
199 country: result.country,
200 cons: [],
201 nonroutable: false,
202 error: None,
203 }
204
205 // Get content as JSON from the event's content function
206 let content = event.content(attrs)
207
208 // Insert at the range from the event
209 let rangeObj: Tiptap.range = {"from": range.from, "to": range.to}
210 editor->Tiptap.Commands.insertContentAtRange(rangeObj, content)
211 }
212 | None => ()
213 }
214 // Close search after selection
215 onSearchSelect->Preact.Signal.set(None)
216 }
217 onSearchSelect->Preact.Signal.set(Some(selectFn))
218 }
219 }
220 }
221
222 // Attach to document - always available, no ref tracking needed
223 let documentElement = Obj.magic(WebAPI.Global.document)
224 let cleanup = PreactDOM.addEventListener(
225 documentElement,
226 "waypointsearch",
227 handleWaypointSearch,
228 )
229 Some(
230 () => {
231 Log.debug("Document listener cleanup - removing event listener")
232 cleanup()
233 },
234 )
235 }
236 }
237 })
238
239 <div className={clsName} onClick={_evt => editor->Tiptap.Commands.focus}>
240 {switch State.content->Preact.Signal.get {
241 | Some(elem) => elem
242 | None =>
243 switch showSearch->Preact.Signal.get {
244 | true => {
245 let onSelect = switch onSearchSelect->Preact.Signal.get {
246 | Some(fn) => fn
247 | None => _result => ()
248 }
249 let onClose = () => onSearchSelect->Preact.Signal.set(None)
250 <WaypointSearch onSelect={onSelect} onClose={onClose} />
251 }
252 | false => <Instance instance={editor} id={doc} className=?{className}> children </Instance>
253 }
254 }}
255 </div>
256}
257
258// Content component - renders the editor content area
259module Content = {
260 @module("@tiptap/react") @scope("Tiptap") @jsx.component
261 external make: (~ref: PreactDOM.Ref.t=?, ~className: string=?) => Jsx.element = "Content"
262}
263
264// BubbleMenu component - context-aware menu that appears on text selection
265module BubbleMenu = {
266 @module("@tiptap/react/menus") @jsx.component
267 external make: (
268 ~updateDelay: int=?,
269 ~resizeDelay: int=?,
270 ~options: FloatingUI.options=?,
271 ~pluginKey: string=?,
272 ~shouldShow: bubbleShouldShowProps => bool=?,
273 ~appendTo: Dom.element=?,
274 ~className: string=?,
275 ~children: Jsx.element=?,
276 ) => Jsx.element = "BubbleMenu"
277}
278
279// FloatingMenu component - context-aware menu that appears on empty lines
280module FloatingMenu = {
281 @module("@tiptap/react/menus") @jsx.component
282 external make: (
283 ~updateDelay: int=?,
284 ~resizeDelay: int=?,
285 ~options: FloatingUI.options=?,
286 ~pluginKey: string=?,
287 ~shouldShow: floatingShouldShowProps => bool=?,
288 ~appendTo: Dom.element=?,
289 ~className: string=?,
290 ~children: Jsx.element=?,
291 ) => Jsx.element = "FloatingMenu"
292}