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}