diff --git a/Cargo.lock b/Cargo.lock index 2b188c1..dc84a1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ dependencies = [ "keyring", "liquid", "liquid-core", - "pulldown-cmark", + "pulldown-cmark 0.13.3", "refinery", "reqwest", "rusqlite", @@ -557,6 +557,7 @@ dependencies = [ "chrono", "dirs 5.0.1", "iced", + "iced_widget", "muda", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -2536,9 +2537,11 @@ dependencies = [ "iced_runtime", "num-traits", "once_cell", + "pulldown-cmark 0.11.3", "rustc-hash 2.1.2", "thiserror 1.0.69", "unicode-segmentation", + "url", ] [[package]] @@ -4417,6 +4420,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pulldown-cmark" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + [[package]] name = "pulldown-cmark" version = "0.13.3" diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index 9686972..5919cf5 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true bds-core = { workspace = true } bds-editor = { workspace = true } iced = { workspace = true } +iced_widget = { version = "0.13.4", features = ["markdown"] } muda = { workspace = true } rfd = { workspace = true } serde_json = { workspace = true } diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 2be3f44..d281e9a 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -275,6 +275,12 @@ fn load_generation_post_body(data_dir: &Path, post: &Post) -> Result) -> &str { + settings_state + .map(|state| state.default_mode.as_str()) + .unwrap_or("markdown") +} + fn save_template_editor_state_impl( db: &Database, project_id: &str, @@ -460,7 +466,7 @@ mod tests { ).unwrap(); let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); - let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string(), "de".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); + let mut editor = PostEditorState::from_post(&editor_post, "markdown", &["en".to_string(), "de".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); editor.title = "Updated Post".to_string(); editor.content = "Updated body".to_string(); editor.tags = vec!["rust".to_string(), "lua".to_string()]; @@ -650,7 +656,7 @@ mod tests { ).unwrap(); let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); - let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); + let mut editor = PostEditorState::from_post(&editor_post, "markdown", &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); editor.title = "Autosaved".to_string(); editor.mark_dirty(); editor.last_edit_at_ms = bds_core::util::now_unix_ms() - POST_AUTO_SAVE_DELAY_MS - 100; @@ -690,7 +696,7 @@ mod tests { ).unwrap(); let editor_post = bds_core::db::queries::post::get_post_by_id(db.conn(), &created.id).unwrap(); - let mut editor = PostEditorState::from_post(&editor_post, &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); + let mut editor = PostEditorState::from_post(&editor_post, "markdown", &["en".to_string()], &[], Vec::new(), Vec::new(), Vec::new()); editor.title = "Switched".to_string(); editor.mark_dirty(); @@ -1650,8 +1656,8 @@ impl BdsApp { // File open handling deferred to later milestones Task::none() } - Message::UrlOpenRequested(_url) => { - // URL open handling deferred to later milestones + Message::UrlOpenRequested(url) => { + let _ = open::that(url); Task::none() } @@ -2116,6 +2122,9 @@ impl BdsApp { PostEditorMsg::AnalyzeTaxonomy => { deferred = DeferredPostAction::AnalyzeTaxonomy(tab_id.clone()); } + PostEditorMsg::SwitchEditorMode(mode) => { + state.set_editor_mode(&mode); + } PostEditorMsg::DetectLanguage => { deferred = DeferredPostAction::DetectLanguage(tab_id.clone()); } @@ -2258,6 +2267,7 @@ impl BdsApp { } if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == state.post_id) { + state.refresh_preview_items(); tab.is_dirty = state.is_dirty; } } @@ -2628,6 +2638,7 @@ impl BdsApp { let linked_media = self.load_post_media_items(&post.id); let state = PostEditorState::from_post( &post, + default_post_editor_mode(self.settings_state.as_ref()), &self.blog_languages, &translations, outlinks, @@ -5424,6 +5435,7 @@ impl BdsApp { post.id.clone(), PostEditorState::from_post( &post, + default_post_editor_mode(self.settings_state.as_ref()), &self.blog_languages, &translations, outlinks, diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index 745cfde..de8c4aa 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use iced::widget::text::{Shaping, Wrapping}; use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space}; use iced::{Color, Element, Length, Theme}; +use iced_widget::markdown; use bds_core::i18n::{self, UiLocale}; use bds_core::model::{Post, PostStatus, PostTranslation}; @@ -56,6 +57,7 @@ pub struct PostEditorState { pub excerpt: String, pub content: String, pub editor_buffer: RefCell, + pub preview_items: Vec, pub tags: Vec, pub categories: Vec, pub author: String, @@ -70,6 +72,7 @@ pub struct PostEditorState { pub last_edit_at_ms: i64, pub metadata_expanded: bool, pub excerpt_expanded: bool, + pub editor_mode: String, pub tags_input: String, pub categories_input: String, // ── Translation flags ── @@ -110,6 +113,7 @@ impl Clone for PostEditorState { excerpt: self.excerpt.clone(), content: self.content.clone(), editor_buffer: RefCell::new(EditorBuffer::new(&self.content)), + preview_items: self.preview_items.clone(), tags: self.tags.clone(), categories: self.categories.clone(), author: self.author.clone(), @@ -124,6 +128,7 @@ impl Clone for PostEditorState { last_edit_at_ms: self.last_edit_at_ms, metadata_expanded: self.metadata_expanded, excerpt_expanded: self.excerpt_expanded, + editor_mode: self.editor_mode.clone(), tags_input: self.tags_input.clone(), categories_input: self.categories_input.clone(), active_language: self.active_language.clone(), @@ -141,6 +146,7 @@ impl Clone for PostEditorState { impl PostEditorState { pub fn from_post( post: &Post, + default_mode: &str, blog_languages: &[String], translations: &[PostTranslation], outlinks: Vec, @@ -148,8 +154,11 @@ impl PostEditorState { linked_media: Vec, ) -> Self { let title = post.title.clone(); + let excerpt = post.excerpt.clone().unwrap_or_default(); let content = post.content.clone().unwrap_or_default(); let canonical_lang = post.language.clone().unwrap_or_else(|| "en".to_string()); + let preview_items = markdown::parse(&preview_markdown_document_parts(&title, &excerpt, &content)) + .collect::>(); let mut translation_drafts = HashMap::new(); for tr in translations { @@ -168,9 +177,10 @@ impl PostEditorState { Self { post_id: post.id.clone(), slug: post.slug.clone(), - excerpt: post.excerpt.clone().unwrap_or_default(), + excerpt, content: content.clone(), editor_buffer: RefCell::new(EditorBuffer::new(&content)), + preview_items, tags: post.tags.clone(), categories: post.categories.clone(), author: post.author.clone().unwrap_or_default(), @@ -185,6 +195,7 @@ impl PostEditorState { last_edit_at_ms: 0, metadata_expanded: title.is_empty(), excerpt_expanded: false, + editor_mode: normalize_editor_mode(default_mode), tags_input: String::new(), categories_input: String::new(), active_language: canonical_lang.clone(), @@ -211,9 +222,18 @@ impl PostEditorState { buffer.text() }; self.content = new_content; + self.refresh_preview_items(); self.mark_dirty(); } + pub fn set_editor_mode(&mut self, mode: &str) { + self.editor_mode = normalize_editor_mode(mode); + } + + pub fn refresh_preview_items(&mut self) { + self.preview_items = markdown::parse(&preview_markdown_document(self)).collect(); + } + /// Switch the editor to display a different language. /// Saves current fields and loads the target language's draft. pub fn switch_language(&mut self, target_lang: &str) { @@ -273,6 +293,7 @@ impl PostEditorState { } self.active_language = target_lang.to_string(); + self.refresh_preview_items(); } /// Build the translation flags list for the view. @@ -327,6 +348,7 @@ pub enum PostEditorMsg { DetectLanguage, Translate, TranslateTo(String), + SwitchEditorMode(String), TitleChanged(String), SlugChanged(String), ExcerptChanged(String), @@ -753,25 +775,39 @@ pub fn view<'a>( // ── Content section (fills remaining space) ── let content_label = inputs::section_header(&t(locale, "editor.content")); + let mode_toggle = row![ + mode_button(locale, &state.editor_mode, "markdown", Message::PostEditor(PostEditorMsg::SwitchEditorMode("markdown".to_string()))), + mode_button(locale, &state.editor_mode, "preview", Message::PostEditor(PostEditorMsg::SwitchEditorMode("preview".to_string()))), + ] + .spacing(6) + .align_y(iced::Alignment::Center); let body_toolbar = inputs::toolbar( - vec![content_label.into()], + vec![row![content_label, Space::with_width(Length::Fixed(12.0)), mode_toggle].align_y(iced::Alignment::Center).into()], vec![ - button( - text(t(locale, "editor.insertLink")) - .size(13) - .shaping(Shaping::Advanced), - ) - .on_press(Message::PostEditor(PostEditorMsg::InsertLink)) - .padding([6, 16]) - .into(), - button( - text(t(locale, "editor.insertMedia")) - .size(13) - .shaping(Shaping::Advanced), - ) - .on_press(Message::PostEditor(PostEditorMsg::InsertMedia)) - .padding([6, 16]) - .into(), + if state.editor_mode == "markdown" { + button( + text(t(locale, "editor.insertLink")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::InsertLink)) + .padding([6, 16]) + .into() + } else { + Space::new(0, 0).into() + }, + if state.editor_mode == "markdown" { + button( + text(t(locale, "editor.insertMedia")) + .size(13) + .shaping(Shaping::Advanced), + ) + .on_press(Message::PostEditor(PostEditorMsg::InsertMedia)) + .padding([6, 16]) + .into() + } else { + Space::new(0, 0).into() + }, button( text(t(locale, "editor.gallery")) .size(13) @@ -782,7 +818,21 @@ pub fn view<'a>( .into(), ], ); - let editor_widget: Element<'a, Message> = + let editor_widget: Element<'a, Message> = if state.editor_mode == "preview" { + scrollable( + container( + markdown::view( + &state.preview_items, + markdown::Settings::with_text_size(15), + markdown::Style::from_palette(Theme::TokyoNightStorm.palette()), + ) + .map(|url: markdown::Url| Message::UrlOpenRequested(url.to_string())), + ) + .padding(16), + ) + .height(Length::Fill) + .into() + } else { CodeEditor::new(&state.editor_buffer, highlighter(), "md") .word_wrap(word_wrap) .on_change(|msg| match msg { @@ -791,7 +841,8 @@ pub fn view<'a>( } EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save), }) - .into(); + .into() + }; // ── Footer ── let published_gap: Element<'a, Message> = if state.published_at.is_some() { @@ -898,6 +949,52 @@ fn chip_button_style(_theme: &Theme, status: button::Status) -> button::Style { } } +fn mode_button<'a>( + locale: UiLocale, + active_mode: &str, + mode: &str, + message: Message, +) -> Element<'a, Message> { + let label = match mode { + "preview" => t(locale, "editor.modePreview"), + _ => t(locale, "editor.modeMarkdown"), + }; + button(text(label).size(12).shaping(Shaping::Advanced)) + .on_press(message) + .padding([4, 10]) + .style(if active_mode == mode { + flag_active_style + } else { + flag_inactive_style + }) + .into() +} + +fn preview_markdown_document(state: &PostEditorState) -> String { + preview_markdown_document_parts(&state.title, &state.excerpt, &state.content) +} + +fn preview_markdown_document_parts(title: &str, excerpt: &str, content: &str) -> String { + let mut sections = Vec::new(); + if !title.trim().is_empty() { + sections.push(format!("# {}", title.trim())); + } + if !excerpt.trim().is_empty() { + sections.push(format!("> {}", excerpt.trim())); + } + if !content.trim().is_empty() { + sections.push(content.to_string()); + } + sections.join("\n\n") +} + +fn normalize_editor_mode(mode: &str) -> String { + match mode { + "preview" => "preview".to_string(), + _ => "markdown".to_string(), + } +} + fn status_badge<'a>(status: &PostStatus) -> Element<'a, Message> { let (label, color) = match status { PostStatus::Draft => ("Draft", Color::from_rgb(0.8, 0.7, 0.2)), @@ -986,6 +1083,7 @@ mod tests { }; PostEditorState::from_post( &post, + "markdown", &["en".to_string()], &[], Vec::new(), @@ -1015,4 +1113,15 @@ mod tests { assert_eq!(state.content, "Hello Rust"); assert!(state.is_dirty); } + + #[test] + fn unsupported_default_mode_falls_back_to_markdown() { + let mut state = sample_state(); + + state.set_editor_mode("wysiwyg"); + assert_eq!(state.editor_mode, "markdown"); + + state.set_editor_mode("preview"); + assert_eq!(state.editor_mode, "preview"); + } } diff --git a/locales/ui/de.json b/locales/ui/de.json index de8c0a6..89d858b 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -225,6 +225,8 @@ "editor.language": "Sprache", "editor.aiAnalyze": "Mit KI analysieren", "editor.detectLanguage": "Sprache erkennen", + "editor.modeMarkdown": "Markdown", + "editor.modePreview": "Vorschau", "editor.suggestTaxonomy": "Taxonomie vorschlagen", "editor.translate": "Übersetzen", "editor.templateSlug": "Vorlage", diff --git a/locales/ui/en.json b/locales/ui/en.json index 8e338c3..d54e7a5 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -222,6 +222,8 @@ "editor.language": "Language", "editor.aiAnalyze": "Analyze with AI", "editor.detectLanguage": "Detect Language", + "editor.modeMarkdown": "Markdown", + "editor.modePreview": "Preview", "editor.suggestTaxonomy": "Suggest Taxonomy", "editor.translate": "Translate", "editor.templateSlug": "Template", diff --git a/locales/ui/es.json b/locales/ui/es.json index 0fc1e94..c9cc14a 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -225,6 +225,8 @@ "editor.language": "Idioma", "editor.aiAnalyze": "Analizar con IA", "editor.detectLanguage": "Detectar idioma", + "editor.modeMarkdown": "Markdown", + "editor.modePreview": "Vista previa", "editor.suggestTaxonomy": "Sugerir taxonomía", "editor.translate": "Traducir", "editor.templateSlug": "Plantilla", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 5d71a99..cf5a900 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -225,6 +225,8 @@ "editor.language": "Langue", "editor.aiAnalyze": "Analyser avec l'IA", "editor.detectLanguage": "Détecter la langue", + "editor.modeMarkdown": "Markdown", + "editor.modePreview": "Aperçu", "editor.suggestTaxonomy": "Suggérer la taxonomie", "editor.translate": "Traduire", "editor.templateSlug": "Modèle", diff --git a/locales/ui/it.json b/locales/ui/it.json index 136b789..f41b43e 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -225,6 +225,8 @@ "editor.language": "Lingua", "editor.aiAnalyze": "Analizza con IA", "editor.detectLanguage": "Rileva lingua", + "editor.modeMarkdown": "Markdown", + "editor.modePreview": "Anteprima", "editor.suggestTaxonomy": "Suggerisci tassonomia", "editor.translate": "Traduci", "editor.templateSlug": "Modello",