cm.rs
| 1 | //! Thin, swappable bindings to the vendored CodeMirror 6 bundle. |
| 2 | //! |
| 3 | //! The bundle (`web/cm6.js`, built from `web/cm/wrapper.js`) installs a |
| 4 | //! `window.__fbng_cm` global. This module is the *only* place that knows how |
| 5 | //! to talk to it, so swapping the editor later means re-implementing just |
| 6 | //! these functions. |
| 7 | |
| 8 | use wasm_bindgen::{JsCast, JsValue}; |
| 9 | |
| 10 | /// A live CodeMirror editor. Destroy it with [`destroy`] when the host goes |
| 11 | /// away (we do that in a Leptos `on_cleanup`). |
| 12 | pub struct Cm { |
| 13 | pub view: JsValue, |
| 14 | } |
| 15 | |
| 16 | /// Extract a human-readable message from a JS error value. |
| 17 | fn err_str(e: JsValue) -> String { |
| 18 | e.clone() |
| 19 | .dyn_into::<js_sys::Error>() |
| 20 | .ok() |
| 21 | .and_then(|e| e.message().as_string()) |
| 22 | .unwrap_or_else(|| "editor error".to_string()) |
| 23 | } |
| 24 | |
| 25 | fn global() -> Result<JsValue, String> { |
| 26 | let w = web_sys::window().ok_or("no window".to_string())?; |
| 27 | let wjs: JsValue = w.into(); |
| 28 | let v = js_sys::Reflect::get(&wjs, &JsValue::from_str("__fbng_cm")) |
| 29 | .map_err(|e| err_str(e))?; |
| 30 | if v.is_null() || v.is_undefined() { |
| 31 | return Err("cm6 bundle not loaded".to_string()); |
| 32 | } |
| 33 | Ok(v) |
| 34 | } |
| 35 | |
| 36 | /// Call `method` on `g` with the given args. |
| 37 | fn call(g: &JsValue, method: &str, args: &[&JsValue]) -> Result<JsValue, String> { |
| 38 | let m = js_sys::Reflect::get(g, &JsValue::from_str(method)).map_err(|e| err_str(e))?; |
| 39 | let f: js_sys::Function = m.unchecked_into(); |
| 40 | let arr = js_sys::Array::new(); |
| 41 | for a in args { |
| 42 | arr.push(a); |
| 43 | } |
| 44 | js_sys::Reflect::apply(&f, g, &arr).map_err(|e| err_str(e)) |
| 45 | } |
| 46 | |
| 47 | /// Can this file name be shown in the text editor? (Delegated to the bundle so |
| 48 | /// the extension list lives in one place.) |
| 49 | pub fn is_text(name: &str) -> bool { |
| 50 | global() |
| 51 | .ok() |
| 52 | .and_then(|g| call(&g, "isText", &[&JsValue::from_str(name)]).ok()) |
| 53 | .and_then(|v| v.as_bool()) |
| 54 | .unwrap_or(false) |
| 55 | } |
| 56 | |
| 57 | /// Create an editor inside `container`. |
| 58 | /// |
| 59 | /// `on_update` is an optional JS function called whenever the document changes |
| 60 | /// (used to track the editor's dirty state). If provided, the caller must keep |
| 61 | /// the backing `Closure` alive for as long as the editor exists. |
| 62 | pub fn create( |
| 63 | container: &web_sys::Element, |
| 64 | value: &str, |
| 65 | filename: &str, |
| 66 | editable: bool, |
| 67 | on_update: Option<&JsValue>, |
| 68 | ) -> Result<Cm, String> { |
| 69 | let g = global()?; |
| 70 | let opts = js_sys::Object::new(); |
| 71 | js_sys::Reflect::set(&opts, &JsValue::from_str("value"), &JsValue::from_str(value)) |
| 72 | .map_err(|e| err_str(e))?; |
| 73 | js_sys::Reflect::set(&opts, &JsValue::from_str("filename"), &JsValue::from_str(filename)) |
| 74 | .map_err(|e| err_str(e))?; |
| 75 | js_sys::Reflect::set(&opts, &JsValue::from_str("editable"), &JsValue::from_bool(editable)) |
| 76 | .map_err(|e| err_str(e))?; |
| 77 | if let Some(f) = on_update { |
| 78 | js_sys::Reflect::set(&opts, &JsValue::from_str("onUpdate"), f) |
| 79 | .map_err(|e| err_str(e))?; |
| 80 | } |
| 81 | |
| 82 | let view = call( |
| 83 | &g, |
| 84 | "create", |
| 85 | &[ |
| 86 | &JsValue::from(container), |
| 87 | &JsValue::from(&opts), |
| 88 | ], |
| 89 | )?; |
| 90 | Ok(Cm { view }) |
| 91 | } |
| 92 | |
| 93 | pub fn destroy(view: &JsValue) { |
| 94 | let Ok(g) = global() else { return }; |
| 95 | let _ = call(&g, "destroy", &[view]); |
| 96 | } |
| 97 | |
| 98 | /// Replace the whole document (used when (re)loading a file). Used from the |
| 99 | /// editor (milestone 5). |
| 100 | pub fn set_value(view: &JsValue, text: &str) { |
| 101 | let Ok(g) = global() else { return }; |
| 102 | let _ = call(&g, "setValue", &[view, &JsValue::from_str(text)]); |
| 103 | } |
| 104 | |
| 105 | /// Read the current document text (used by the editor's save path, milestone |
| 106 | /// 5). |
| 107 | pub fn get_value(view: &JsValue) -> Option<String> { |
| 108 | let g = global().ok()?; |
| 109 | call(&g, "getValue", &[view]).ok()?.as_string() |
| 110 | } |
| 111 | |
| 112 | /// Focus the editor (so the caret is ready for typing on open). |
| 113 | pub fn focus(view: &JsValue) { |
| 114 | let Ok(g) = global() else { return }; |
| 115 | let _ = call(&g, "focus", &[view]); |
| 116 | } |
| 117 |