Skip to main content

kode_leptos/
extension.rs

1//! Extension system for the kode WYSIWYG editor.
2//!
3//! Extensions can customize code block rendering, add toolbar buttons,
4//! register keyboard shortcuts, and respond to editor lifecycle events.
5
6use std::sync::Arc;
7
8use kode_core::Editor;
9use kode_markdown::{FormattingState, MarkdownEditor};
10use leptos::tachys::view::any_view::AnyView;
11
12/// Context passed to extension lifecycle hooks — a read-only view of editor state.
13///
14pub struct ExtensionEditorContext<'a> {
15    /// The underlying kode-core `Editor` (cursor, selection, buffer).
16    pub editor: &'a Editor,
17    /// The full markdown source text.
18    pub source: &'a str,
19    /// Current cursor byte offset in the source.
20    pub cursor_byte: usize,
21    /// Current formatting state (inline + block).
22    pub formatting: &'a FormattingState,
23}
24
25/// A toolbar button contributed by an extension.
26pub struct ExtensionToolbarItem {
27    /// Button content — text, icon, or any Leptos view.
28    pub label: AnyView,
29    /// Tooltip/title text.
30    pub title: String,
31    /// Toolbar group index (for visual grouping with separators).
32    pub group: u8,
33    /// Action to execute when clicked. Receives mutable editor access.
34    pub action: Arc<dyn Fn(&mut MarkdownEditor) + Send + Sync>,
35    /// Name used for active state matching (matched against `active_state` output).
36    pub active_name: Option<String>,
37}
38
39/// A keyboard shortcut contributed by an extension.
40pub struct ExtensionKeyboardShortcut {
41    /// Key descriptor (e.g. "Mod-Shift-c", "Ctrl-Enter").
42    ///
43    /// Format: modifier segments joined by `-`, where modifiers are:
44    /// - `Mod` or `Ctrl` — maps to Ctrl (or Cmd on macOS)
45    /// - `Shift`
46    /// - `Alt`
47    ///
48    /// The final segment is the key name (matching `KeyboardEvent.key()`).
49    pub key: String,
50    /// Handler. Returns `true` if the shortcut was consumed.
51    pub handler: Arc<dyn Fn(&mut MarkdownEditor) -> bool + Send + Sync>,
52}
53
54/// A WYSIWYG editor extension that can customize rendering, toolbar,
55/// keyboard shortcuts, and editor behavior.
56pub trait Extension: Send + Sync {
57    /// Unique name for this extension (e.g. "chartml", "table", "mermaid").
58    fn name(&self) -> &str;
59
60    // ── Block rendering ──────────────────────────────────────────
61
62    /// Fenced code block languages this extension handles.
63    ///
64    /// When a fenced code block's language matches one of these strings,
65    /// `render_code_block` is called instead of the default syntax-highlighted
66    /// renderer.
67    ///
68    /// Return an empty slice if this extension doesn't handle code blocks.
69    fn code_block_languages(&self) -> &[&str] {
70        &[]
71    }
72
73    /// Render a custom fenced code block.
74    ///
75    /// Called when the block's language matches one from `code_block_languages()`.
76    ///
77    /// - `language`: the fenced block language identifier
78    /// - `content`: the raw content inside the fenced block
79    /// - `block_start`: position of the block start (token position in tree mode)
80    /// - `block_end`: position of the block end (token position in tree mode)
81    ///
82    /// Positions are opaque integers used for identification, not arithmetic.
83    /// In tree mode they are token positions; in legacy mode they are byte offsets.
84    ///
85    /// Returns a Leptos view to render, or `None` to fall back to the default
86    /// syntax-highlighted code block.
87    fn render_code_block(
88        &self,
89        _language: &str,
90        _content: &str,
91        _block_start: usize,
92        _block_end: usize,
93    ) -> Option<AnyView> {
94        None
95    }
96
97    // ── Toolbar ──────────────────────────────────────────────────
98
99    /// Additional toolbar buttons this extension provides.
100    ///
101    /// These are appended after the built-in toolbar buttons.
102    /// Each item defines: label, title, group index, and action callback.
103    fn toolbar_items(&self) -> Vec<ExtensionToolbarItem> {
104        vec![]
105    }
106
107    // ── Keyboard shortcuts ───────────────────────────────────────
108
109    /// Keyboard shortcuts this extension handles.
110    ///
111    /// Each shortcut has a key descriptor and handler function.
112    /// Return `true` from the handler if the shortcut was consumed.
113    fn keyboard_shortcuts(&self) -> Vec<ExtensionKeyboardShortcut> {
114        vec![]
115    }
116
117    // ── Render pass ───────────────────────────────────────────────
118
119    /// Called before each render pass of the document tree.
120    ///
121    /// Extensions that cache rendered views (e.g. expensive chart rendering)
122    /// can use this to reset per-pass counters and prune stale cache entries.
123    fn begin_render_pass(&self) {}
124
125    // ── Lifecycle ────────────────────────────────────────────────
126
127    /// Called when the editor is created. Use for one-time setup.
128    ///
129    fn on_create(&self, _ctx: &ExtensionEditorContext) {}
130
131    /// Called when the editor is destroyed. Use for cleanup.
132    ///
133    fn on_destroy(&self) {}
134
135    /// Called when the document content changes.
136    ///
137    fn on_update(&self, _ctx: &ExtensionEditorContext) {}
138
139    /// Called when the cursor/selection changes.
140    /// Extensions can use this to update their own reactive state.
141    ///
142    fn on_selection_update(&self, _ctx: &ExtensionEditorContext) {}
143
144    // ── Formatting state ─────────────────────────────────────────
145
146    /// Extend the formatting state with extension-specific active states.
147    ///
148    /// Called on every cursor move. Return a list of `(name, is_active)` pairs.
149    /// These are used to highlight extension toolbar buttons whose
150    /// `active_name` matches.
151    fn active_state(&self, _ctx: &ExtensionEditorContext) -> Vec<(&str, bool)> {
152        vec![]
153    }
154}
155
156/// Check whether a `KeyboardEvent` matches an extension key descriptor.
157///
158/// Descriptor format: `"Mod-Shift-c"`, `"Ctrl-Enter"`, `"Alt-Shift-x"`, etc.
159/// - `Mod` and `Ctrl` both map to `ctrl_key() || meta_key()`
160/// - `Shift` maps to `shift_key()`
161/// - `Alt` maps to `alt_key()`
162/// - The remaining (non-modifier) portion is matched case-insensitively against `ev.key()`.
163///
164/// Parsing consumes known modifier prefixes from left; whatever remains is the
165/// key name.  This handles descriptors like `"Ctrl--"` (ctrl + minus) correctly,
166/// where a naive split-on-`-` would break.
167/// Parse a key descriptor into its modifier flags and key name.
168///
169/// Returns `(need_ctrl, need_shift, need_alt, key_name)`.
170/// Returns `None` if the descriptor is malformed (empty key name).
171fn parse_key_descriptor(descriptor: &str) -> Option<(bool, bool, bool, &str)> {
172    let mut need_ctrl = false;
173    let mut need_shift = false;
174    let mut need_alt = false;
175    let mut remaining = descriptor;
176
177    loop {
178        if let Some(rest) = remaining.strip_prefix("Mod-") {
179            need_ctrl = true;
180            remaining = rest;
181        } else if let Some(rest) = remaining.strip_prefix("Ctrl-") {
182            need_ctrl = true;
183            remaining = rest;
184        } else if let Some(rest) = remaining.strip_prefix("Meta-") {
185            need_ctrl = true;
186            remaining = rest;
187        } else if let Some(rest) = remaining.strip_prefix("Shift-") {
188            need_shift = true;
189            remaining = rest;
190        } else if let Some(rest) = remaining.strip_prefix("Alt-") {
191            need_alt = true;
192            remaining = rest;
193        } else {
194            break;
195        }
196    }
197
198    if remaining.is_empty() {
199        None
200    } else {
201        Some((need_ctrl, need_shift, need_alt, remaining))
202    }
203}
204
205pub(crate) fn matches_key_descriptor(ev: &web_sys::KeyboardEvent, descriptor: &str) -> bool {
206    let Some((need_ctrl, need_shift, need_alt, key_part)) = parse_key_descriptor(descriptor)
207    else {
208        return false;
209    };
210
211    let has_ctrl = ev.ctrl_key() || ev.meta_key();
212    if need_ctrl != has_ctrl {
213        return false;
214    }
215    if need_shift != ev.shift_key() {
216        return false;
217    }
218    if need_alt != ev.alt_key() {
219        return false;
220    }
221
222    ev.key().eq_ignore_ascii_case(key_part)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn parse_simple_key() {
231        let (ctrl, shift, alt, key) = parse_key_descriptor("a").unwrap();
232        assert!(!ctrl && !shift && !alt);
233        assert_eq!(key, "a");
234    }
235
236    #[test]
237    fn parse_ctrl_key() {
238        let (ctrl, shift, alt, key) = parse_key_descriptor("Ctrl-b").unwrap();
239        assert!(ctrl && !shift && !alt);
240        assert_eq!(key, "b");
241    }
242
243    #[test]
244    fn parse_mod_key() {
245        let (ctrl, shift, alt, key) = parse_key_descriptor("Mod-b").unwrap();
246        assert!(ctrl && !shift && !alt);
247        assert_eq!(key, "b");
248    }
249
250    #[test]
251    fn parse_mod_shift_key() {
252        let (ctrl, shift, alt, key) = parse_key_descriptor("Mod-Shift-c").unwrap();
253        assert!(ctrl && shift && !alt);
254        assert_eq!(key, "c");
255    }
256
257    #[test]
258    fn parse_ctrl_alt_key() {
259        let (ctrl, shift, alt, key) = parse_key_descriptor("Ctrl-Alt-Delete").unwrap();
260        assert!(ctrl && !shift && alt);
261        assert_eq!(key, "Delete");
262    }
263
264    #[test]
265    fn parse_ctrl_minus() {
266        // "Ctrl--" means Ctrl + the minus key
267        let (ctrl, shift, alt, key) = parse_key_descriptor("Ctrl--").unwrap();
268        assert!(ctrl && !shift && !alt);
269        assert_eq!(key, "-");
270    }
271
272    #[test]
273    fn parse_just_minus() {
274        let (ctrl, shift, alt, key) = parse_key_descriptor("-").unwrap();
275        assert!(!ctrl && !shift && !alt);
276        assert_eq!(key, "-");
277    }
278
279    #[test]
280    fn parse_enter() {
281        let (ctrl, shift, alt, key) = parse_key_descriptor("Enter").unwrap();
282        assert!(!ctrl && !shift && !alt);
283        assert_eq!(key, "Enter");
284    }
285
286    #[test]
287    fn parse_mod_enter() {
288        let (ctrl, shift, alt, key) = parse_key_descriptor("Mod-Enter").unwrap();
289        assert!(ctrl && !shift && !alt);
290        assert_eq!(key, "Enter");
291    }
292
293    #[test]
294    fn parse_empty_returns_none() {
295        assert!(parse_key_descriptor("").is_none());
296    }
297
298    #[test]
299    fn parse_trailing_dash_returns_none() {
300        // "Ctrl-" has no key name after the modifier
301        assert!(parse_key_descriptor("Ctrl-").is_none());
302    }
303
304    #[test]
305    fn parse_all_modifiers() {
306        let (ctrl, shift, alt, key) =
307            parse_key_descriptor("Ctrl-Shift-Alt-x").unwrap();
308        assert!(ctrl && shift && alt);
309        assert_eq!(key, "x");
310    }
311
312    #[test]
313    fn parse_meta_maps_to_ctrl() {
314        let (ctrl, _, _, key) = parse_key_descriptor("Meta-b").unwrap();
315        assert!(ctrl);
316        assert_eq!(key, "b");
317    }
318}