1use laterite_core::{Db, Text};
13use serde::Serialize;
14use std::sync::Arc;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Surface {
19 Rich,
21 Markdown,
23}
24
25impl Surface {
26 pub fn key(self) -> &'static str {
28 match self {
29 Self::Rich => "richeditor",
30 Self::Markdown => "markdown",
31 }
32 }
33
34 pub(crate) fn parse(key: &str) -> Option<Self> {
35 match key {
36 "richeditor" => Some(Self::Rich),
37 "markdown" => Some(Self::Markdown),
38 _ => None,
39 }
40 }
41}
42
43#[derive(Debug, Clone)]
47pub enum Insert {
48 Html(String),
49 Text(String),
50}
51
52#[derive(Debug, Clone)]
54pub struct EditorCommand {
55 pub surface: Surface,
56 pub key: String,
58 pub label: Text,
59 pub insert: Insert,
60}
61
62impl EditorCommand {
63 pub fn new(
64 surface: Surface,
65 key: impl Into<String>,
66 label: impl Into<Text>,
67 insert: Insert,
68 ) -> Self {
69 Self {
70 surface,
71 key: key.into(),
72 label: label.into(),
73 insert,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "lowercase")]
81pub enum SnippetPropertyKind {
82 String,
84 Dropdown,
86 Checkbox,
88}
89
90#[derive(Debug, Clone, Serialize)]
92pub struct SnippetProperty {
93 pub code: String,
94 pub title: String,
95 pub kind: SnippetPropertyKind,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub default: Option<serde_json::Value>,
98 #[serde(skip_serializing_if = "Vec::is_empty")]
100 pub options: Vec<(String, String)>,
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct Snippet {
106 pub code: String,
107 pub name: String,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub description: Option<String>,
110 pub properties: Vec<SnippetProperty>,
111}
112
113#[laterite_core::strata::async_trait]
116pub trait SnippetSource: Send + Sync {
117 async fn list(&self, db: &Db) -> Vec<Snippet>;
118}
119
120#[derive(Clone)]
122pub struct SnippetSourceReg(pub Arc<dyn SnippetSource>);
123
124#[derive(Debug, Default, Serialize)]
127pub struct Menu {
128 pub commands: Vec<MenuCommand>,
129 pub snippets: Vec<Snippet>,
130}
131
132#[derive(Debug, Serialize)]
133pub struct MenuCommand {
134 pub key: String,
135 pub label: String,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub html: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub text: Option<String>,
140}
141
142pub(crate) fn commands<'a>(
144 all: impl Iterator<Item = &'a EditorCommand>,
145 surface: Surface,
146) -> Vec<&'a EditorCommand> {
147 let mut out: Vec<&EditorCommand> = Vec::new();
148 for cmd in all.filter(|c| c.surface == surface) {
149 let fits = matches!(
150 (surface, &cmd.insert),
151 (Surface::Rich, Insert::Html(_)) | (Surface::Markdown, Insert::Text(_))
152 );
153 if !fits {
154 tracing::warn!(
155 key = cmd.key,
156 surface = surface.key(),
157 "editor command inserts the wrong kind for its surface; dropped"
158 );
159 continue;
160 }
161 if let Some(at) = out.iter().position(|c| c.key == cmd.key) {
162 out[at] = cmd;
163 } else {
164 out.push(cmd);
165 }
166 }
167 out
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn commands_are_filtered_by_surface_and_kind_and_deduped_by_key() {
176 let all = [
177 EditorCommand::new(Surface::Rich, "hr", "Rule", Insert::Html("<hr>".into())),
178 EditorCommand::new(Surface::Rich, "bad", "Bad", Insert::Text("x".into())),
179 EditorCommand::new(Surface::Markdown, "hr", "Rule", Insert::Text("---".into())),
180 EditorCommand::new(
181 Surface::Rich,
182 "hr",
183 "Rule 2",
184 Insert::Html("<hr class=a>".into()),
185 ),
186 ];
187 let rich = commands(all.iter(), Surface::Rich);
188 assert_eq!(rich.len(), 1);
189 assert!(matches!(&rich[0].insert, Insert::Html(h) if h == "<hr class=a>"));
190 assert_eq!(commands(all.iter(), Surface::Markdown).len(), 1);
191 }
192}