Skip to main content

taino_edit_dioxus/
lib.rs

1//! `taino-edit-dioxus` — the Dioxus adapter for taino-edit.
2//!
3//! Mirrors `taino-edit-leptos`: a [`TainoEditor`] component takes a
4//! [`Signal<EditorState>`] and mounts a [`taino_edit_dom::EditorView`]
5//! inside its rendered `<div>`, reconciling the DOM on every signal
6//! change and folding browser-side edits back into the signal.
7//!
8//! Browser events (`input`, `compositionstart`/`compositionend`, `paste`,
9//! pointer `mousedown`/`mousemove`/`mouseup`, `selectionchange`) are wired
10//! with the same raw `web-sys` listeners the Leptos adapter uses — they are
11//! registered on the mounted element (and, for `selectionchange`, on
12//! `document`) and kept alive in the component's runtime slot. Optional
13//! [`ViewPlugin`]s (e.g. `TableView`) can be installed via the [`ViewPlugins`]
14//! prop, giving full event- and plugin-wiring parity with `taino-edit-leptos`.
15
16#![deny(unsafe_code)]
17#![forbid(unstable_features)]
18#![warn(missing_docs, rust_2018_idioms)]
19
20use std::cell::{Cell, RefCell};
21use std::rc::Rc;
22
23use dioxus::prelude::*;
24use wasm_bindgen::prelude::*;
25use wasm_bindgen::JsCast;
26
27/// The [`schema!`](taino_edit_core::schema) builder macro.
28pub use taino_edit_core::schema;
29/// Re-export the core types adapter consumers reach for most.
30#[doc(no_inline)]
31pub use taino_edit_core::{
32    base_keymap, lift, remove_mark, select_all, set_block_type, set_mark, split_block, toggle_mark,
33    wrap_in, AttrSpec, AttrValue, Attrs, Command, Dispatch, EditorState, KeyPress, Keymap, Mark,
34    MarkSpec, MarkType, Node, NodeSpec, NodeType, Plugin, PluginKey, PluginSet, ResolvedPos,
35    Schema, SchemaBuilder, Selection, Slice, Transaction, Transform,
36};
37/// Re-export the DOM-bridge surface.
38#[doc(no_inline)]
39pub use taino_edit_dom::{Decoration, EditorView, ViewAction, ViewDesc, ViewPlugin};
40
41/// A move-once container of DOM-aware [`ViewPlugin`]s for the
42/// [`TainoEditor`] `plugins` prop.
43///
44/// Dioxus props must be `Clone + PartialEq`; a bare
45/// `Vec<Box<dyn ViewPlugin>>` is neither, so the plugins live behind a shared
46/// cell that is cheap to clone. They are installed on the view exactly once
47/// at mount and never compared afterwards, so this type is deliberately
48/// "always equal": changing the prop after mount has no effect and must not
49/// trigger a re-render.
50///
51/// ```ignore
52/// use taino_edit_dioxus::{TainoEditor, ViewPlugins};
53/// use taino_edit_table_view::TableView;
54///
55/// rsx! { TainoEditor { state, plugins: ViewPlugins::new(vec![Box::new(TableView::new())]) } }
56/// ```
57#[derive(Clone, Default)]
58pub struct ViewPlugins(PluginCell);
59
60/// The shared, take-once backing store behind [`ViewPlugins`].
61type PluginCell = Rc<RefCell<Option<Vec<Box<dyn ViewPlugin>>>>>;
62
63impl ViewPlugins {
64    /// Wrap a set of view plugins for the `plugins` prop.
65    pub fn new(plugins: Vec<Box<dyn ViewPlugin>>) -> Self {
66        Self(Rc::new(RefCell::new(Some(plugins))))
67    }
68
69    /// Take the plugins out, leaving the container empty. Called once at
70    /// mount; any later call yields an empty vec.
71    fn take(&self) -> Vec<Box<dyn ViewPlugin>> {
72        self.0.borrow_mut().take().unwrap_or_default()
73    }
74}
75
76impl PartialEq for ViewPlugins {
77    // Mount-only and never re-read: every value compares equal so the prop
78    // can't cause spurious re-renders of `TainoEditor`.
79    fn eq(&self, _other: &Self) -> bool {
80        true
81    }
82}
83
84impl std::fmt::Debug for ViewPlugins {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        let n = self.0.borrow().as_ref().map_or(0, Vec::len);
87        f.debug_struct("ViewPlugins").field("pending", &n).finish()
88    }
89}
90
91/// A take-once container for the [`TainoEditor`] `keymap` prop. Mirrors
92/// [`ViewPlugins`]: Dioxus props must be `Clone + PartialEq` and [`Keymap`] is
93/// neither, so the keymap lives behind a shared cell and is moved into the
94/// view at mount.
95///
96/// ```ignore
97/// rsx! { TainoEditor { state, keymap: KeymapProp::new(my_keymap) } }
98/// ```
99#[derive(Clone, Default)]
100pub struct KeymapProp(Rc<RefCell<Option<Keymap>>>);
101
102impl KeymapProp {
103    /// Wrap a keymap for the `keymap` prop.
104    pub fn new(keymap: Keymap) -> Self {
105        Self(Rc::new(RefCell::new(Some(keymap))))
106    }
107
108    /// Take the keymap out (once, at mount).
109    fn take(&self) -> Option<Keymap> {
110        self.0.borrow_mut().take()
111    }
112}
113
114impl PartialEq for KeymapProp {
115    // Mount-only; never re-read, so the prop never forces a re-render.
116    fn eq(&self, _other: &Self) -> bool {
117        true
118    }
119}
120
121impl std::fmt::Debug for KeymapProp {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("KeymapProp").finish_non_exhaustive()
124    }
125}
126
127/// A Dioxus component that renders an editor backed by a
128/// [`Signal<EditorState>`]. Whenever the signal changes, the mounted DOM is
129/// reconciled via [`EditorView::update`]; browser-side edits (typing, IME
130/// commits, paste, selection changes) feed back into the signal by applying
131/// the transforms the DOM bridge produces.
132///
133/// ```ignore
134/// use dioxus::prelude::*;
135/// use taino_edit_dioxus::{EditorState, TainoEditor};
136///
137/// #[component]
138/// fn App(state: Signal<EditorState>) -> Element {
139///     rsx! { TainoEditor { state } }
140/// }
141/// ```
142#[component]
143pub fn TainoEditor(
144    state: Signal<EditorState>,
145    /// Optional DOM-aware [`ViewPlugin`]s (e.g. `TableView` for table
146    /// cell-drag-select + resize). Installed on the view at mount; the
147    /// component wires pointer events to them and refreshes their
148    /// decorations on every state change.
149    #[props(default)]
150    plugins: ViewPlugins,
151    /// Optional [`Keymap`] for keyboard editing. When provided, the component
152    /// owns `keydown`: it reads the *live* DOM selection, runs the matching
153    /// command, and applies the result to the view **synchronously** (so the
154    /// caret and DOM never lag the model). Build it with
155    /// `taino_edit_extensions::build_keymap_with`.
156    #[props(default)]
157    keymap: KeymapProp,
158) -> Element {
159    // The mounted view + its event closures live here across renders.
160    // EditorView is !Send + !Sync, which Dioxus signals tolerate.
161    let mut runtime: Signal<Option<EditorRuntime>> = use_signal(|| None);
162
163    // On every state change, patch the DOM and re-sync the selection.
164    use_effect(move || {
165        let snapshot = state.read().clone();
166        if let Some(rt) = runtime.write().as_mut() {
167            rt.view.update(snapshot.doc().clone());
168            // Only re-sync the DOM selection when the editor is focused, so we
169            // never steal focus back from another element (e.g. a search box).
170            //
171            // Never for updates that merely mirror a selection the browser
172            // already has (`selection_from_dom`): the effect runs after the
173            // `selectionchange` handler, and the user may have extended the
174            // selection further in between (e.g. mid drag-select). Writing
175            // the mirrored — by now stale — range back would clip the live
176            // selection's tail.
177            let mirrored_from_dom = rt.selection_from_dom.replace(false);
178            if !mirrored_from_dom
179                && rt.view.has_focus()
180                && rt.view.read_selection() != Some(snapshot.selection())
181            {
182                rt.applying_selection.set(true);
183                let _ = rt.view.set_selection(snapshot.selection());
184                rt.applying_selection.set(false);
185            }
186            // Refresh plugin decorations (e.g. table cell-selection
187            // highlight) for the current selection.
188            rt.view.refresh_view_decorations(Some(snapshot.selection()));
189        }
190    });
191
192    let on_mounted = move |evt: Event<MountedData>| {
193        let Some(element) = evt.data().downcast::<web_sys::Element>().cloned() else {
194            return;
195        };
196        let snapshot = state.read().clone();
197        let mut view = EditorView::mount(
198            snapshot.doc().clone(),
199            snapshot.schema().clone(),
200            element.clone(),
201        );
202        view.set_view_plugins(plugins.take());
203        view.refresh_view_decorations(Some(snapshot.selection()));
204        let applying = Rc::new(Cell::new(false));
205        let from_dom = Rc::new(Cell::new(false));
206        // Park the keymap behind a shared cell so the keydown closure (and
207        // the runtime, for diagnostics) can reach it.
208        let keymap_cell: Rc<RefCell<Option<Keymap>>> = Rc::new(RefCell::new(keymap.take()));
209        let closures = wire_events(
210            &element,
211            runtime,
212            state,
213            applying.clone(),
214            from_dom.clone(),
215            keymap_cell.clone(),
216        );
217        runtime.set(Some(EditorRuntime {
218            view,
219            closures,
220            applying_selection: applying,
221            selection_from_dom: from_dom,
222            keymap: keymap_cell,
223        }));
224    };
225
226    rsx! {
227        div {
228            class: "taino-editor",
229            onmounted: on_mounted,
230        }
231    }
232}
233
234/// What a mounted `TainoEditor` owns. Dropping this both drops the view
235/// (frees the DOM-bound `EditorView`) and detaches every event listener.
236struct EditorRuntime {
237    view: EditorView,
238    #[allow(dead_code)] // kept alive so the listeners they back stay attached.
239    closures: Vec<EventCloser>,
240    /// Set while the effect pushes state's selection into the DOM, so the
241    /// `selectionchange` listener can ignore the resulting echo.
242    applying_selection: Rc<Cell<bool>>,
243    /// Set by the `selectionchange` listener when a state update merely
244    /// mirrors a selection the browser already has; consumed by the effect,
245    /// which must then *not* write that (possibly already stale) selection
246    /// back into the DOM.
247    selection_from_dom: Rc<Cell<bool>>,
248    /// The installed keymap (if any). Shared with the `keydown` closure so it
249    /// can run commands synchronously against the live state.
250    #[allow(dead_code)] // accessed via the keydown closure's clone of the Rc.
251    keymap: Rc<RefCell<Option<Keymap>>>,
252}
253
254/// A `Closure` registered on a DOM target; on drop the listener is removed.
255struct EventCloser {
256    event: &'static str,
257    target: web_sys::EventTarget,
258    closure: Closure<dyn FnMut(web_sys::Event)>,
259    /// Whether the listener was registered in the capture phase (must match on
260    /// removal). Used for `scroll`, which does not bubble.
261    capture: bool,
262}
263
264impl Drop for EventCloser {
265    fn drop(&mut self) {
266        let _ = self.target.remove_event_listener_with_callback_and_bool(
267            self.event,
268            self.closure.as_ref().unchecked_ref(),
269            self.capture,
270        );
271    }
272}
273
274fn push_listener(
275    closers: &mut Vec<EventCloser>,
276    target: web_sys::EventTarget,
277    event: &'static str,
278    closure: Closure<dyn FnMut(web_sys::Event)>,
279) {
280    push_listener_capture(closers, target, event, closure, false);
281}
282
283fn push_listener_capture(
284    closers: &mut Vec<EventCloser>,
285    target: web_sys::EventTarget,
286    event: &'static str,
287    closure: Closure<dyn FnMut(web_sys::Event)>,
288    capture: bool,
289) {
290    if target
291        .add_event_listener_with_callback_and_bool(event, closure.as_ref().unchecked_ref(), capture)
292        .is_ok()
293    {
294        closers.push(EventCloser {
295            event,
296            target,
297            closure,
298            capture,
299        });
300    }
301}
302
303fn wire_events(
304    el: &web_sys::Element,
305    mut runtime: Signal<Option<EditorRuntime>>,
306    mut state: Signal<EditorState>,
307    applying_selection: Rc<Cell<bool>>,
308    selection_from_dom: Rc<Cell<bool>>,
309    keymap_cell: Rc<RefCell<Option<Keymap>>>,
310) -> Vec<EventCloser> {
311    let target: web_sys::EventTarget = el.clone().into();
312    let mut closers: Vec<EventCloser> = Vec::new();
313
314    // `input`: text typed or deleted in a text node.
315    let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |_ev: web_sys::Event| {
316        if let Some(Some(t)) = with_view(runtime, |v| v.read_dom_changes()) {
317            apply_transform(state, &t);
318        }
319    });
320    push_listener(&mut closers, target.clone(), "input", cb);
321
322    // `keydown`: with a keymap installed, the editor owns keyboard editing.
323    // Read the *live* DOM selection so the command acts on the real caret,
324    // not a lagging model selection; then apply view.update + set_selection
325    // **synchronously** so the DOM/caret can't fall out of step before the
326    // next keystroke.
327    let km_for_keydown = keymap_cell;
328    let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |ev: web_sys::Event| {
329        let Ok(kev) = ev.dyn_into::<web_sys::KeyboardEvent>() else {
330            return;
331        };
332        let key = KeyPress {
333            key: kev.key(),
334            ctrl: kev.ctrl_key(),
335            alt: kev.alt_key(),
336            shift: kev.shift_key(),
337            meta: kev.meta_key(),
338        };
339        let mut cur = state.peek().clone();
340        if let Some(Some(live)) = with_view(runtime, |v| v.read_selection()) {
341            if live != cur.selection() {
342                let mut tx = cur.tr();
343                tx.set_selection(live);
344                tx.no_history();
345                cur = cur.apply(tx);
346            }
347        }
348        let mut next = None;
349        let handled = match km_for_keydown.borrow().as_ref() {
350            Some(km) => {
351                let mut d = |t: Transaction| next = Some(cur.apply(t));
352                km.handle(&cur, &key, Some(&mut d))
353            }
354            None => false,
355        };
356        if let Some(n) = next {
357            // Apply synchronously to the mounted view, then publish to state.
358            if let Some(rt) = runtime.write().as_mut() {
359                rt.view.update(n.doc().clone());
360                rt.applying_selection.set(true);
361                let _ = rt.view.set_selection(n.selection());
362                rt.applying_selection.set(false);
363                rt.view.refresh_view_decorations(Some(n.selection()));
364            }
365            state.set(n);
366        }
367        // Structural keys are model-authoritative.
368        let structural = matches!(key.key.as_str(), "Enter" | "Backspace" | "Delete");
369        if handled || structural {
370            kev.prevent_default();
371        }
372    });
373    push_listener(&mut closers, target.clone(), "keydown", cb);
374
375    // IME composition: suspend reads while composing, commit on end.
376    let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |_ev: web_sys::Event| {
377        with_view(runtime, |v| v.composition_start());
378    });
379    push_listener(&mut closers, target.clone(), "compositionstart", cb);
380
381    let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |_ev: web_sys::Event| {
382        let t = with_view(runtime, |v| {
383            v.composition_end();
384            v.read_dom_changes()
385        })
386        .flatten();
387        if let Some(t) = t {
388            apply_transform(state, &t);
389        }
390    });
391    push_listener(&mut closers, target.clone(), "compositionend", cb);
392
393    // Paste: prefer Markdown, then HTML, then plain text — all sanitised
394    // through the schema-aware paths in core.
395    let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |ev: web_sys::Event| {
396        let Ok(clip) = ev.dyn_into::<web_sys::ClipboardEvent>() else {
397            return;
398        };
399        clip.prevent_default();
400        let Some(data) = clip.clipboard_data() else {
401            return;
402        };
403        let md = data.get_data("text/markdown").unwrap_or_default();
404        let html = data.get_data("text/html").unwrap_or_default();
405        let text = data.get_data("text/plain").unwrap_or_default();
406        let t = with_view(runtime, |v| {
407            if !md.is_empty() {
408                v.paste_markdown(&md)
409            } else if !html.is_empty() {
410                v.paste_html(&html)
411            } else if !text.is_empty() {
412                v.paste_text(&text)
413            } else {
414                None
415            }
416        })
417        .flatten();
418        if let Some(t) = t {
419            apply_transform(state, &t);
420        }
421    });
422    push_listener(&mut closers, target.clone(), "paste", cb);
423
424    // Pointer events → view plugins (table cell-drag-select, resize). Each
425    // fires `handle_view_event`; a returned action is applied to state.
426    // No-op when no plugin claims the event.
427    for kind in ["mousedown", "mousemove", "mouseup"] {
428        let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |ev: web_sys::Event| {
429            if let Some(Some(action)) = with_view(runtime, |v| v.handle_view_event(&ev)) {
430                apply_view_action(state, action);
431            }
432        });
433        push_listener(&mut closers, target.clone(), kind, cb);
434    }
435
436    // `selectionchange` only fires on `document`; mirror the browser
437    // selection into state so toolbar/keymap commands see the right
438    // anchor/head. Drop the echo from our own effect-driven set_selection.
439    if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
440        let doc_target: web_sys::EventTarget = doc.into();
441        let applying = applying_selection;
442        let from_dom = selection_from_dom;
443        let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |_ev: web_sys::Event| {
444            if applying.get() {
445                return;
446            }
447            let Some(Some(sel)) = with_view(runtime, |v| v.read_selection()) else {
448                return;
449            };
450            let cur = state.peek().selection();
451            if sel == cur {
452                return;
453            }
454            // Mark this update as a DOM-driven mirror so the effect doesn't
455            // write it back into the browser (see the effect for why).
456            from_dom.set(true);
457            let mut s = state;
458            let next = {
459                let snap = s.peek();
460                let mut tx = snap.tr();
461                tx.set_selection(sel);
462                tx.no_history();
463                snap.apply(tx)
464            };
465            s.set(next);
466        });
467        push_listener(&mut closers, doc_target, "selectionchange", cb);
468    }
469
470    // Reposition inline-decoration overlays when the layout shifts without a
471    // document edit. `scroll` is captured (it doesn't bubble) so editor- or
472    // ancestor-level scrolling is caught too; `resize` fires on `window`.
473    if let Some(window) = web_sys::window() {
474        let win_target: web_sys::EventTarget = window.unchecked_into();
475        let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |_ev: web_sys::Event| {
476            with_view(runtime, |v| v.reposition_inline_decorations());
477        });
478        push_listener_capture(&mut closers, win_target.clone(), "scroll", cb, true);
479        let cb = Closure::<dyn FnMut(web_sys::Event)>::new(move |_ev: web_sys::Event| {
480            with_view(runtime, |v| v.reposition_inline_decorations());
481        });
482        push_listener(&mut closers, win_target, "resize", cb);
483    }
484
485    closers
486}
487
488/// Run `f` against the mounted `EditorView`, if any.
489fn with_view<R>(
490    runtime: Signal<Option<EditorRuntime>>,
491    f: impl FnOnce(&EditorView) -> R,
492) -> Option<R> {
493    runtime.peek().as_ref().map(|rt| f(&rt.view))
494}
495
496/// Apply a [`ViewAction`] produced by a view plugin to the state signal.
497fn apply_view_action(mut state: Signal<EditorState>, action: ViewAction) {
498    match action {
499        ViewAction::Select(sel) => {
500            let next = {
501                let snap = state.peek();
502                let mut tx = snap.tr();
503                tx.set_selection(sel);
504                tx.no_history();
505                snap.apply(tx)
506            };
507            state.set(next);
508        }
509        ViewAction::Command(cmd) => {
510            let snapshot = state.peek().clone();
511            let mut next = None;
512            {
513                let mut d = |tx: Transaction| next = Some(snapshot.apply(tx));
514                cmd(&snapshot, Some(&mut d));
515            }
516            if let Some(n) = next {
517                state.set(n);
518            }
519        }
520    }
521}
522
523/// Fold a DOM-bridge transform into the state signal.
524fn apply_transform(mut state: Signal<EditorState>, tr: &Transform) {
525    let next = {
526        let snap = state.peek();
527        let mut tx = snap.tr();
528        let mut ok = true;
529        for step in tr.steps() {
530            if tx.transform().step(step.clone(), snap.schema()).is_err() {
531                ok = false;
532                break;
533            }
534        }
535        if !ok {
536            return;
537        }
538        snap.apply(tx)
539    };
540    state.set(next);
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    /// A trivial plugin (all-default trait impl) for exercising `ViewPlugins`.
548    struct Dummy;
549    impl ViewPlugin for Dummy {}
550
551    #[test]
552    fn view_plugins_take_is_once() {
553        let p = ViewPlugins::new(vec![Box::new(Dummy), Box::new(Dummy)]);
554        assert_eq!(p.take().len(), 2, "first take yields the installed plugins");
555        assert_eq!(
556            p.take().len(),
557            0,
558            "the container is empty after the first take"
559        );
560    }
561
562    #[test]
563    fn view_plugins_always_compare_equal() {
564        // Mount-only prop: every value is "equal" so it never forces a
565        // re-render of `TainoEditor`.
566        assert_eq!(
567            ViewPlugins::new(vec![Box::new(Dummy)]),
568            ViewPlugins::default()
569        );
570    }
571}