ftml_viewer/
ts.rs

1#![allow(non_local_definitions)]
2
3use std::collections::HashMap;
4
5use flams_ontology::{
6    narration::problems::{
7        ProblemFeedback, ProblemFeedbackJson, ProblemResponse, SolutionData, Solutions,
8    },
9    uris::{DocumentElementURI, DocumentURI},
10};
11use ftml_viewer_components::{
12    components::{
13        documents::{
14            DocumentFromURI, DocumentString, FragmentFromURI, FragmentString, FragmentStringProps,
15        },
16        problem::ProblemState as OrigState,
17        Gotto, TOCElem, TOCSource,
18    },
19    ts::{
20        FragmentContinuation, InputRefContinuation, JFragCont, JInputRefCont, JOnSectTtl, JsOrRsF,
21        LeptosContext, NamedJsFunction, OnSectionTitle, TsTopCont,
22    },
23    AllowHovers, ProblemOptions,
24};
25use leptos::{either::Either, prelude::*};
26use wasm_bindgen::prelude::wasm_bindgen;
27
28#[wasm_bindgen(js_name = injectCss)]
29pub fn inject_css(mut css: flams_utils::CSS) {
30    if let flams_utils::CSS::Link(lnk) = &mut css {
31        if let Some(r) = lnk.strip_prefix("srv:") {
32            *lnk = format!("{}{r}", ftml_viewer_components::remote::get_server_url()).into();
33        }
34    }
35    flams_web_utils::do_css(css)
36}
37
38#[wasm_bindgen] //(js_name = setDebugLog)]
39/// activates debug logging
40pub fn set_debug_log() {
41    let _ = tracing_wasm::try_set_as_global_default();
42    console_error_panic_hook::set_once();
43}
44
45#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, tsify_next::Tsify)]
46#[tsify(into_wasm_abi, from_wasm_abi)]
47#[serde(tag = "type")]
48/// State of a particular problem
49pub enum ProblemState {
50    /// Users can provide/change their answers
51    Interactive {
52        /// initial response (if a user has already selected an answer)
53        #[serde(default)]
54        current_response: Option<ProblemResponse>,
55        /// The solution ( => ftml-viewer will take care of matching a response to this solution and compute feedback accordingly )
56        #[serde(default)]
57        solution: Option<Box<[SolutionData]>>,
58    },
59    /// No change to the response possible anymore
60    Finished {
61        #[serde(default)]
62        current_response: Option<ProblemResponse>,
63    },
64    /// Fully graded; feedback provided
65    Graded { feedback: ProblemFeedbackJson },
66}
67impl From<ProblemState> for OrigState {
68    fn from(value: ProblemState) -> Self {
69        match value {
70            ProblemState::Interactive {
71                current_response,
72                solution,
73            } => Self::Interactive {
74                current_response,
75                solution: solution.map(Solutions::from_solutions),
76            },
77            ProblemState::Finished { current_response } => Self::Finished { current_response },
78            ProblemState::Graded { feedback } => Self::Graded {
79                feedback: ProblemFeedback::from_json(feedback),
80            },
81        }
82    }
83}
84
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, tsify_next::Tsify)]
86#[tsify(into_wasm_abi, from_wasm_abi)]
87#[serde(transparent)]
88pub struct ProblemStates(pub HashMap<DocumentElementURI, ProblemState>);
89
90fn convert(
91    on_response: Option<JProblemCont>,
92    state: Option<ProblemStates>,
93) -> Option<ProblemOptions> {
94    if on_response.is_some() || state.is_some() {
95        Some(ProblemOptions {
96            on_response: on_response.map(|j| JsOrRsF::Js(j.get().into())),
97            states: state
98                .map(|e| e.0.into_iter().map(|(k, v)| (k, v.into())).collect())
99                .unwrap_or_default(),
100        })
101    } else {
102        None
103    }
104}
105
106#[wasm_bindgen]
107/// sets up a leptos context for rendering FTML documents or fragments.
108/// If a context already exists, does nothing, so is cheap to call
109/// [render_document] and [render_fragment] also inject a context
110/// iff none already exists, so this is optional in every case.
111pub fn ftml_setup(
112    to: leptos::web_sys::HtmlElement,
113    children: TsTopCont,
114    allow_hovers: Option<bool>,
115    on_section_title: Option<JOnSectTtl>,
116    on_fragment: Option<JFragCont>,
117    on_inputref: Option<JInputRefCont>,
118    on_problem: Option<JProblemCont>,
119    problem_states: Option<ProblemStates>,
120) -> FTMLMountHandle {
121    let allow_hovers = allow_hovers.unwrap_or(true);
122    let children = children.to_cont();
123    let on_section_title = on_section_title.map(|f| OnSectionTitle(f.get().into()));
124    let on_fragment = on_fragment.map(|f| f.get().into());
125    let on_inputref = on_inputref.map(|f| f.get().into());
126    let problem_opts = convert(on_problem, problem_states);
127
128    FTMLMountHandle::new(to, move || {
129        view! {
130          <GlobalSetup allow_hovers on_fragment on_section_title on_inputref problem_opts>{
131            let ret = NodeRef::new();
132            ret.on_load(move |e| {
133              let owner = Owner::current().expect("Not in a leptos reactive context!");
134              if let Err(e) = children.apply(&(e,owner.into())) {
135                tracing::error!("Error calling continuation: {e}");
136              }
137            });
138            view!(<div node_ref = ret/>)
139          }</GlobalSetup>
140        }
141    })
142}
143
144#[allow(clippy::needless_pass_by_value)]
145#[wasm_bindgen]
146/// render an FTML document to the provided element
147/// #### Errors
148pub fn render_document(
149    to: leptos::web_sys::HtmlElement,
150    document: DocumentOptions,
151    context: Option<LeptosContext>,
152    allow_hovers: Option<bool>,
153    on_section_title: Option<JOnSectTtl>,
154    on_fragment: Option<JFragCont>,
155    on_inputref: Option<JInputRefCont>,
156    on_problem: Option<JProblemCont>,
157    problem_states: Option<ProblemStates>,
158) -> Result<FTMLMountHandle, String> {
159    fn inner(
160        to: leptos::web_sys::HtmlElement,
161        document: DocumentOptions,
162        allow_hovers: bool,
163        on_section_title: Option<JOnSectTtl>,
164        on_fragment: Option<JFragCont>,
165        on_inputref: Option<JInputRefCont>,
166        problem_opts: Option<ProblemOptions>,
167    ) -> Result<FTMLMountHandle, String> {
168        let on_section_title = on_section_title.map(|f| OnSectionTitle(f.get().into()));
169        let on_fragment = on_fragment.map(|f| f.get().into());
170        let on_inputref = on_inputref.map(|f| f.get().into());
171
172        let comp = move || match document {
173            DocumentOptions::HtmlString { html, gottos, toc } => {
174                let toc = toc.map_or(TOCSource::None, TOCSource::Ready);
175                let gottos = gottos.unwrap_or_default();
176                Either::Left(
177                    view! {<GlobalSetup allow_hovers on_section_title on_fragment on_inputref problem_opts>
178                        <DocumentString html gottos toc/>
179                    </GlobalSetup>},
180                )
181            }
182            DocumentOptions::FromBackend { uri, gottos, toc } => {
183                let toc = toc.map_or(TOCSource::None, Into::into);
184                let gottos = gottos.unwrap_or_default();
185                Either::Right(
186                    view! {<GlobalSetup allow_hovers on_section_title on_fragment on_inputref problem_opts>
187                        <DocumentFromURI uri gottos toc/>
188                    </GlobalSetup>},
189                )
190            }
191        };
192
193        Ok(FTMLMountHandle::new(to, move || comp()))
194    }
195    let allow_hovers = allow_hovers.unwrap_or(true);
196    let problem_opts = convert(on_problem, problem_states);
197    if let Some(context) = context {
198        context.with(move || {
199            inner(
200                to,
201                document,
202                allow_hovers,
203                on_section_title,
204                on_fragment,
205                on_inputref,
206                problem_opts,
207            )
208        })
209    } else {
210        inner(
211            to,
212            document,
213            allow_hovers,
214            on_section_title,
215            on_fragment,
216            on_inputref,
217            problem_opts,
218        )
219    }
220}
221
222#[allow(clippy::needless_pass_by_value)]
223#[wasm_bindgen]
224/// render an FTML document fragment to the provided element
225/// #### Errors
226pub fn render_fragment(
227    to: leptos::web_sys::HtmlElement,
228    fragment: FragmentOptions,
229    context: Option<LeptosContext>,
230    allow_hovers: Option<bool>,
231    on_section_title: Option<JOnSectTtl>,
232    on_fragment: Option<JFragCont>,
233    on_inputref: Option<JInputRefCont>,
234    on_problem: Option<JProblemCont>,
235    problem_states: Option<ProblemStates>,
236) -> Result<FTMLMountHandle, String> {
237    fn inner(
238        to: leptos::web_sys::HtmlElement,
239        fragment: FragmentOptions,
240        allow_hovers: bool,
241        on_section_title: Option<JOnSectTtl>,
242        on_fragment: Option<JFragCont>,
243        on_inputref: Option<JInputRefCont>,
244        problem_opts: Option<ProblemOptions>,
245    ) -> Result<FTMLMountHandle, String> {
246        let _ = to.style().set_property(
247            "--rustex-this-width",
248            "var(--rustex-curr-width,min(800px,100%))",
249        );
250        let on_section_title = on_section_title.map(|f| OnSectionTitle(f.get().into()));
251        let on_fragment = on_fragment.map(|f| f.get().into());
252        let on_inputref = on_inputref.map(|f| f.get().into());
253
254        let comp = move || match fragment {
255            FragmentOptions::HtmlString { html, uri } => {
256                Either::Left(FragmentString(FragmentStringProps { html, uri }))
257            }
258            FragmentOptions::FromBackend { uri } => Either::Right(view! {<FragmentFromURI uri/>}),
259        };
260        Ok(FTMLMountHandle::new(to, move || {
261            view! {<GlobalSetup allow_hovers on_section_title on_fragment on_inputref problem_opts>
262                {comp()}
263            </GlobalSetup>}
264        }))
265    }
266    let allow_hovers = allow_hovers.unwrap_or(true);
267    let problem_opts = convert(on_problem, problem_states);
268    if let Some(context) = context {
269        context.with(move || {
270            inner(
271                to,
272                fragment,
273                allow_hovers,
274                on_section_title,
275                on_fragment,
276                on_inputref,
277                problem_opts,
278            )
279        })
280    } else {
281        inner(
282            to,
283            fragment,
284            allow_hovers,
285            on_section_title,
286            on_fragment,
287            on_inputref,
288            problem_opts,
289        )
290    }
291}
292
293#[component]
294fn GlobalSetup<V: IntoView + 'static>(
295    #[prop(default = true)] allow_hovers: bool,
296    #[prop(default=None)] on_section_title: Option<OnSectionTitle>,
297    #[prop(default=None)] on_fragment: Option<FragmentContinuation>,
298    #[prop(default=None)] on_inputref: Option<InputRefContinuation>,
299    #[prop(default=None)] problem_opts: Option<ProblemOptions>,
300    children: TypedChildren<V>,
301) -> impl IntoView {
302    use flams_web_utils::components::Themer;
303    use ftml_viewer_components::FTMLGlobalSetup;
304    //use leptos::either::Either as E;
305    use leptos::either::Either::{Left, Right};
306    console_error_panic_hook::set_once();
307    let children = children.into_inner();
308    let children = move || {
309        if let Some(on_section_title) = on_section_title {
310            provide_context(Some(on_section_title));
311        }
312        if let Some(on_fragment) = on_fragment {
313            provide_context(Some(on_fragment));
314        }
315        if let Some(on_inputref) = on_inputref {
316            provide_context(Some(on_inputref));
317        }
318        if let Some(problem_opts) = problem_opts {
319            provide_context(problem_opts);
320        }
321        provide_context(AllowHovers(allow_hovers));
322        children()
323    };
324
325    let children = move || {
326        if ftml_viewer_components::is_in_ftml() {
327            Left(children())
328        } else {
329            Right(view!(<FTMLGlobalSetup>{children()}</FTMLGlobalSetup>))
330        }
331    };
332
333    //let r = owner.with(move || {
334    if with_context::<thaw::ConfigInjection, _>(|_| ()).is_none() {
335        Left(
336            view!(<Themer attr:style="font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit;background-color:inherit;">{children()}</Themer>),
337        )
338    } else {
339        Right(children())
340    }
341    //});
342    //on_cleanup(move || drop(owner));
343    //r
344}
345
346#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, tsify_next::Tsify)]
347#[tsify(into_wasm_abi, from_wasm_abi)]
348/// Options for rendering an FTML document
349/// - `FromBackend`: calls the backend for the document
350///     uri: the URI of the document (as string)
351///     toc: if defined, will render a table of contents for the document
352// - Prerendered: Take the existent DOM HTMLElement as is
353/// - `HtmlString`: render the provided HTML String
354///     html: the HTML String
355///     toc: if defined, will render a table of contents for the document
356#[serde(tag = "type")]
357pub enum DocumentOptions {
358    FromBackend {
359        uri: DocumentURI,
360        #[serde(default)]
361        gottos: Option<Vec<Gotto>>,
362        //#[serde(default)] <- this breaks toc:"GET" for some reason
363        toc: Option<TOCOptions>,
364    },
365    //Prerendered,
366    HtmlString {
367        html: String,
368        #[serde(default)]
369        gottos: Option<Vec<Gotto>>,
370        //#[serde(default)] <- this breaks toc:"GET" for some reason
371        toc: Option<Vec<TOCElem>>,
372    },
373}
374
375#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, tsify_next::Tsify)]
376#[tsify(into_wasm_abi, from_wasm_abi)]
377#[serde(tag = "type")]
378/// Options for rendering an FTML document fragment
379/// - `FromBackend`: calls the backend for the document fragment
380///     uri: the URI of the document fragment (as string)
381// - Prerendered: Take the existent DOM HTMLElement as is
382/// - `HtmlString`: render the provided HTML String
383///     html: the HTML String
384pub enum FragmentOptions {
385    FromBackend {
386        uri: DocumentElementURI,
387    },
388    //Prerendered,
389    HtmlString {
390        html: String,
391        #[serde(default)]
392        uri: Option<DocumentElementURI>,
393    },
394}
395
396#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, tsify_next::Tsify)]
397#[tsify(into_wasm_abi, from_wasm_abi)]
398/// Options for rendering a table of contents
399/// `GET` will retrieve it from the remote backend
400/// `TOCElem[]` will render the provided TOC
401pub enum TOCOptions {
402    #[serde(rename = "GET")]
403    GET,
404    Predefined(Vec<TOCElem>),
405}
406
407impl From<TOCOptions> for TOCSource {
408    fn from(value: TOCOptions) -> Self {
409        match value {
410            TOCOptions::GET => Self::Get,
411            TOCOptions::Predefined(toc) => Self::Ready(toc),
412        }
413    }
414}
415
416ftml_viewer_components::ts_function! {
417  JProblemCont ProblemCont @ "(r:ProblemResponse) => void"
418  = ProblemResponse => ()
419}
420
421/*
422#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, tsify_next::Tsify)]
423#[tsify(into_wasm_abi, from_wasm_abi)]
424pub enum ProblemOption {
425    WithFeedback(Vec<(DocumentElementURI, ProblemFeedback)>),
426    WithSolutions(Vec<(DocumentElementURI, Solutions)>),
427}
428impl Into<ProblemOptions> for ProblemOption {
429    fn into(self) -> ProblemOptions {
430        match self {
431            Self::WithFeedback(v) => ProblemOptions::WithFeedback(v.into()),
432            Self::WithSolutions(s) => ProblemOptions::WithSolutions(s.into()),
433        }
434    }
435}
436 */
437
438#[wasm_bindgen]
439pub struct FTMLMountHandle {
440    mount: std::cell::Cell<
441        Option<leptos::prelude::UnmountHandle<leptos::tachys::view::any_view::AnyViewState>>,
442    >,
443}
444
445#[wasm_bindgen]
446impl FTMLMountHandle {
447    /// unmounts the view and cleans up the reactive system.
448    /// Not calling this is a memory leak
449    pub fn unmount(&self) -> Result<(), wasm_bindgen::JsError> {
450        if let Some(mount) = self.mount.take() {
451            drop(mount); //try_catch(move || drop(mount))?;
452        }
453        Ok(())
454    }
455    fn new<V: IntoView + 'static>(
456        div: leptos::web_sys::HtmlElement,
457        f: impl FnOnce() -> V + 'static,
458    ) -> Self {
459        let handle = leptos::prelude::mount_to(div, move || f().into_any());
460        Self {
461            mount: std::cell::Cell::new(Some(handle)),
462        }
463    }
464}