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}