fix: hopefully preview works now
This commit is contained in:
793
Cargo.lock
generated
793
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ chrono = { workspace = true }
|
||||
open = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
wry = "0.55"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
objc2 = "0.6"
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Datelike;
|
||||
use iced::{Element, Subscription, Task};
|
||||
use iced::{Element, Subscription, Task, window};
|
||||
use serde_json::json;
|
||||
|
||||
use bds_core::db::Database;
|
||||
@@ -13,6 +13,7 @@ use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
|
||||
use bds_core::i18n::{detect_os_locale, UiLocale};
|
||||
use bds_core::model::{Media, Post, PostStatus, Project, PublishingPreferences, Script, SshMode, Template};
|
||||
|
||||
use crate::components::webview::{self, WebViewConfig, WebViewController};
|
||||
use crate::i18n::{t, tw};
|
||||
use crate::platform::menu::{self, MenuAction, MenuRegistry};
|
||||
use crate::state::navigation::{
|
||||
@@ -81,6 +82,8 @@ pub enum Message {
|
||||
// macOS lifecycle
|
||||
FileOpenRequested(PathBuf),
|
||||
UrlOpenRequested(String),
|
||||
MainWindowLoaded(Option<window::Id>),
|
||||
EmbeddedPreviewReady(Result<(), String>),
|
||||
|
||||
// Panel
|
||||
SetPanelTab(PanelTab),
|
||||
@@ -225,6 +228,11 @@ struct PreviewSession {
|
||||
handle: engine::preview::PreviewServerHandle,
|
||||
}
|
||||
|
||||
struct EmbeddedPreviewState {
|
||||
controller: WebViewController,
|
||||
current_url: Option<String>,
|
||||
}
|
||||
|
||||
fn persist_media_editor_state_impl(
|
||||
db: &Database,
|
||||
data_dir: &Path,
|
||||
@@ -281,6 +289,35 @@ fn default_post_editor_mode(settings_state: Option<&SettingsViewState>) -> &str
|
||||
.unwrap_or("markdown")
|
||||
}
|
||||
|
||||
fn referenced_media_ids(content: &str) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
let mut rest = content;
|
||||
|
||||
while let Some(start) = rest.find("bds-media://") {
|
||||
let suffix = &rest[start + "bds-media://".len()..];
|
||||
let end = suffix
|
||||
.find(|ch: char| ch == ')' || ch == ']' || ch.is_whitespace())
|
||||
.unwrap_or(suffix.len());
|
||||
let media_id = suffix[..end].trim();
|
||||
if !media_id.is_empty() && !ids.iter().any(|existing| existing == media_id) {
|
||||
ids.push(media_id.to_string());
|
||||
}
|
||||
rest = &suffix[end..];
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
fn draft_preview_url(post_id: &str, language: &str) -> String {
|
||||
format!(
|
||||
"http://{}:{}/__draft/{}?language={}",
|
||||
engine::preview::PREVIEW_HOST,
|
||||
engine::preview::PREVIEW_PORT,
|
||||
post_id,
|
||||
language
|
||||
)
|
||||
}
|
||||
|
||||
fn save_template_editor_state_impl(
|
||||
db: &Database,
|
||||
project_id: &str,
|
||||
@@ -639,6 +676,47 @@ mod tests {
|
||||
assert_eq!(diff.value, "side-by-side");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_post_media_items_includes_media_referenced_only_in_markdown() {
|
||||
let (db, project, tmp) = setup();
|
||||
let source = tmp.path().join("tiny.png");
|
||||
std::fs::write(&source, tiny_png_bytes()).unwrap();
|
||||
let imported = media::import_media(
|
||||
db.conn(),
|
||||
tmp.path(),
|
||||
&project.id,
|
||||
&source,
|
||||
"tiny.png",
|
||||
Some("Tiny"),
|
||||
Some("Alt"),
|
||||
None,
|
||||
None,
|
||||
Some("en"),
|
||||
vec!["photo".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
let post = post::create_post(
|
||||
db.conn(),
|
||||
tmp.path(),
|
||||
&project.id,
|
||||
"Referenced",
|
||||
Some(&format!("", imported.id)),
|
||||
Vec::new(),
|
||||
vec!["article".to_string()],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let app = make_app(db, project, &tmp);
|
||||
|
||||
let linked_media = app.load_post_media_items(&post.id, post.content.as_deref());
|
||||
|
||||
assert_eq!(linked_media.len(), 1);
|
||||
assert_eq!(linked_media[0].media_id, imported.id);
|
||||
assert!(linked_media[0].is_image);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_tick_autosaves_dirty_post_editor() {
|
||||
let (db, project, tmp) = setup();
|
||||
@@ -1178,6 +1256,8 @@ pub struct BdsApp {
|
||||
|
||||
// Local preview
|
||||
preview_session: Option<PreviewSession>,
|
||||
embedded_preview: Option<EmbeddedPreviewState>,
|
||||
main_window_id: Option<window::Id>,
|
||||
|
||||
// Editor states (keyed by entity id)
|
||||
post_editors: HashMap<String, PostEditorState>,
|
||||
@@ -1256,7 +1336,11 @@ impl BdsApp {
|
||||
|
||||
// Chain menu initialization after project loading
|
||||
// (must happen after the event loop has started for macOS)
|
||||
let init_task = Task::batch([init_task, Task::done(Message::InitMenuBar)]);
|
||||
let init_task = Task::batch([
|
||||
init_task,
|
||||
Task::done(Message::InitMenuBar),
|
||||
window::get_oldest().map(Message::MainWindowLoaded),
|
||||
]);
|
||||
registry.set_enabled(MenuAction::Save, false);
|
||||
registry.set_enabled(MenuAction::PublishSelected, false);
|
||||
registry.set_enabled(MenuAction::PreviewPost, false);
|
||||
@@ -1306,6 +1390,8 @@ impl BdsApp {
|
||||
toasts: Vec::new(),
|
||||
active_modal: None,
|
||||
preview_session: None,
|
||||
embedded_preview: None,
|
||||
main_window_id: None,
|
||||
post_editors: HashMap::new(),
|
||||
media_editors: HashMap::new(),
|
||||
template_editors: HashMap::new(),
|
||||
@@ -1361,6 +1447,8 @@ impl BdsApp {
|
||||
toasts: Vec::new(),
|
||||
active_modal: None,
|
||||
preview_session: None,
|
||||
embedded_preview: None,
|
||||
main_window_id: None,
|
||||
post_editors: HashMap::new(),
|
||||
media_editors: HashMap::new(),
|
||||
template_editors: HashMap::new(),
|
||||
@@ -1457,7 +1545,7 @@ impl BdsApp {
|
||||
}
|
||||
self.enforce_panel_tab_fallback();
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
self.sync_embedded_preview_for_active_post()
|
||||
}
|
||||
Message::CloseTab(id) => {
|
||||
if self.active_tab.as_deref() == Some(id.as_str()) {
|
||||
@@ -1470,7 +1558,7 @@ impl BdsApp {
|
||||
}
|
||||
self.enforce_panel_tab_fallback();
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
self.sync_embedded_preview_for_active_post()
|
||||
}
|
||||
Message::SelectTab(id) => {
|
||||
if self.tabs.iter().any(|t| t.id == id) {
|
||||
@@ -1480,7 +1568,7 @@ impl BdsApp {
|
||||
self.active_tab = Some(id);
|
||||
}
|
||||
self.enforce_panel_tab_fallback();
|
||||
Task::none()
|
||||
self.sync_embedded_preview_for_active_post()
|
||||
}
|
||||
Message::PinTab(id) => {
|
||||
tabs::pin_tab(&mut self.tabs, &id);
|
||||
@@ -1490,6 +1578,7 @@ impl BdsApp {
|
||||
self.flush_active_post_editor();
|
||||
self.tabs.clear();
|
||||
self.active_tab = None;
|
||||
self.hide_embedded_preview();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -1532,6 +1621,7 @@ impl BdsApp {
|
||||
Ok(()) => {
|
||||
self.active_project = self.projects.iter().find(|p| p.id == project_id).cloned();
|
||||
self.preview_session = None;
|
||||
self.hide_embedded_preview();
|
||||
self.data_dir = self
|
||||
.active_project
|
||||
.as_ref()
|
||||
@@ -1643,6 +1733,32 @@ impl BdsApp {
|
||||
// Media import will be expanded in later milestones
|
||||
Task::none()
|
||||
}
|
||||
Message::MainWindowLoaded(window_id) => {
|
||||
self.main_window_id = window_id;
|
||||
if self.active_post_uses_embedded_preview() {
|
||||
self.sync_embedded_preview_for_active_post()
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::EmbeddedPreviewReady(result) => {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let visible = self.active_post_uses_embedded_preview();
|
||||
if let Some(preview) = &mut self.embedded_preview {
|
||||
preview.controller.take_staged();
|
||||
if let Some(url) = preview.current_url.as_deref() {
|
||||
preview.controller.navigate(url);
|
||||
}
|
||||
preview.controller.set_visible(visible);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Tasks ──
|
||||
Message::TaskTick => {
|
||||
@@ -2082,6 +2198,7 @@ impl BdsApp {
|
||||
Message::PostEditor(msg) => {
|
||||
enum DeferredPostAction {
|
||||
None,
|
||||
SyncEmbeddedPreview,
|
||||
Analyze(String),
|
||||
AnalyzeTaxonomy(String),
|
||||
DetectLanguage(String),
|
||||
@@ -2113,6 +2230,7 @@ impl BdsApp {
|
||||
}
|
||||
|
||||
let mut deferred = DeferredPostAction::None;
|
||||
let mut refresh_linked_media: Option<(String, String)> = None;
|
||||
if let Some(tab_id) = self.active_tab.clone() {
|
||||
if let Some(state) = self.post_editors.get_mut(&tab_id) {
|
||||
match msg {
|
||||
@@ -2129,6 +2247,7 @@ impl BdsApp {
|
||||
}
|
||||
PostEditorMsg::SwitchEditorMode(mode) => {
|
||||
state.set_editor_mode(&mode);
|
||||
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
||||
}
|
||||
PostEditorMsg::DetectLanguage => {
|
||||
state.quick_actions_open = false;
|
||||
@@ -2147,6 +2266,7 @@ impl BdsApp {
|
||||
PostEditorMsg::ContentChanged(new_text) => {
|
||||
state.content = new_text;
|
||||
state.mark_dirty();
|
||||
refresh_linked_media = Some((state.post_id.clone(), state.content.clone()));
|
||||
}
|
||||
PostEditorMsg::AuthorChanged(s) => { state.author = s; state.mark_dirty(); }
|
||||
PostEditorMsg::LanguageChanged(s) => { state.language = s; state.mark_dirty(); }
|
||||
@@ -2154,7 +2274,12 @@ impl BdsApp {
|
||||
PostEditorMsg::ToggleDoNotTranslate(b) => { state.do_not_translate = b; state.mark_dirty(); }
|
||||
PostEditorMsg::ToggleMetadata => { state.metadata_expanded = !state.metadata_expanded; }
|
||||
PostEditorMsg::ToggleExcerpt => { state.excerpt_expanded = !state.excerpt_expanded; }
|
||||
PostEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); }
|
||||
PostEditorMsg::SwitchLanguage(lang) => {
|
||||
state.switch_language(&lang);
|
||||
if state.editor_mode == "preview" {
|
||||
deferred = DeferredPostAction::SyncEmbeddedPreview;
|
||||
}
|
||||
}
|
||||
PostEditorMsg::TagsInputChanged(s) => { state.tags_input = s; }
|
||||
PostEditorMsg::TagsInputSubmit => {
|
||||
let tag = state.tags_input.trim().to_string();
|
||||
@@ -2274,14 +2399,21 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((post_id, content)) = refresh_linked_media {
|
||||
let linked_media = self.load_post_media_items(&post_id, Some(&content));
|
||||
if let Some(state) = self.post_editors.get_mut(&post_id) {
|
||||
state.linked_media = linked_media;
|
||||
}
|
||||
}
|
||||
|
||||
match deferred {
|
||||
DeferredPostAction::None => Task::none(),
|
||||
DeferredPostAction::SyncEmbeddedPreview => self.sync_embedded_preview_for_active_post(),
|
||||
DeferredPostAction::Analyze(tab_id) => self.run_post_ai_analysis(&tab_id),
|
||||
DeferredPostAction::AnalyzeTaxonomy(tab_id) => self.run_post_taxonomy_analysis(&tab_id),
|
||||
DeferredPostAction::DetectLanguage(tab_id) => self.detect_post_language(&tab_id),
|
||||
@@ -2642,7 +2774,7 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
||||
let linked_media = self.load_post_media_items(&post.id);
|
||||
let linked_media = self.load_post_media_items(&post.id, post.content.as_deref());
|
||||
let state = PostEditorState::from_post(
|
||||
&post,
|
||||
default_post_editor_mode(self.settings_state.as_ref()),
|
||||
@@ -2712,6 +2844,13 @@ impl BdsApp {
|
||||
SidebarView::Pages => &self.page_filter,
|
||||
_ => &self.post_filter,
|
||||
};
|
||||
let post_preview_widget = if self.active_post_uses_embedded_preview() {
|
||||
self.embedded_preview
|
||||
.as_ref()
|
||||
.map(|preview| webview::webview(&preview.controller).into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
workspace::view(
|
||||
self.sidebar_view,
|
||||
@@ -2745,6 +2884,7 @@ impl BdsApp {
|
||||
&self.toasts,
|
||||
self.active_modal.clone(),
|
||||
self.data_dir.as_deref(),
|
||||
post_preview_widget,
|
||||
&self.post_editors,
|
||||
&self.media_editors,
|
||||
&self.template_editors,
|
||||
@@ -5356,12 +5496,12 @@ impl BdsApp {
|
||||
(outlinks, backlinks)
|
||||
}
|
||||
|
||||
fn load_post_media_items(&self, post_id: &str) -> Vec<LinkedMediaItem> {
|
||||
fn load_post_media_items(&self, post_id: &str, content: Option<&str>) -> Vec<LinkedMediaItem> {
|
||||
let Some(ref db) = self.db else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
bds_core::db::queries::post_media::list_post_media_by_post(db.conn(), post_id)
|
||||
let mut media_by_id = bds_core::db::queries::post_media::list_post_media_by_post(db.conn(), post_id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|link| {
|
||||
@@ -5370,16 +5510,45 @@ impl BdsApp {
|
||||
.map(|media| LinkedMediaItem {
|
||||
media_id: media.id,
|
||||
name: media.title.unwrap_or(media.original_name),
|
||||
file_path: media.file_path,
|
||||
is_image: media.mime_type.starts_with("image/"),
|
||||
sort_order: link.sort_order,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.map(|media| (media.media_id.clone(), media))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
for media_id in referenced_media_ids(content.unwrap_or_default()) {
|
||||
if media_by_id.contains_key(&media_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(media) = bds_core::db::queries::media::get_media_by_id(db.conn(), &media_id) {
|
||||
media_by_id.insert(
|
||||
media_id,
|
||||
LinkedMediaItem {
|
||||
media_id: media.id,
|
||||
name: media.title.unwrap_or(media.original_name),
|
||||
file_path: media.file_path,
|
||||
is_image: media.mime_type.starts_with("image/"),
|
||||
sort_order: i32::MAX,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut items = media_by_id.into_values().collect::<Vec<_>>();
|
||||
items.sort_by_key(|media| media.sort_order);
|
||||
items
|
||||
}
|
||||
|
||||
fn refresh_post_relationships(&mut self, post_id: &str) {
|
||||
let (outlinks, backlinks) = self.load_post_links(post_id);
|
||||
let linked_media = self.load_post_media_items(post_id);
|
||||
let current_content = self
|
||||
.post_editors
|
||||
.get(post_id)
|
||||
.map(|state| state.content.clone());
|
||||
let linked_media = self.load_post_media_items(post_id, current_content.as_deref());
|
||||
if let Some(state) = self.post_editors.get_mut(post_id) {
|
||||
state.outlinks = outlinks;
|
||||
state.backlinks = backlinks;
|
||||
@@ -5437,7 +5606,7 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
let (outlinks, backlinks) = self.load_post_links(&post.id);
|
||||
let linked_media = self.load_post_media_items(&post.id);
|
||||
let linked_media = self.load_post_media_items(&post.id, post.content.as_deref());
|
||||
self.post_editors.insert(
|
||||
post.id.clone(),
|
||||
PostEditorState::from_post(
|
||||
@@ -5677,29 +5846,12 @@ impl BdsApp {
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_active_post(&mut self) -> Task<Message> {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self
|
||||
.tabs
|
||||
.iter()
|
||||
.any(|tab| tab.id == active_id && tab.tab_type == TabType::Post)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Err(error) = self.persist_post_editor_state(&active_id) {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
fn ensure_preview_server(&mut self) -> Result<(), String> {
|
||||
let Some(project) = self.active_project.as_ref() else {
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.generateSiteNoProject"));
|
||||
return Task::none();
|
||||
return Err(t(self.ui_locale, "engine.generateSiteNoProject"));
|
||||
};
|
||||
let Some(data_dir) = self.data_dir.clone() else {
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
||||
return Task::none();
|
||||
return Err(t(self.ui_locale, "engine.previewDataDirUnavailable"));
|
||||
};
|
||||
|
||||
let should_restart = self
|
||||
@@ -5721,12 +5873,10 @@ impl BdsApp {
|
||||
});
|
||||
}
|
||||
Err(engine::EngineError::Conflict(_)) if self.preview_session.is_none() => {
|
||||
self.notify(ToastLevel::Error, &t(self.ui_locale, "engine.previewPortInUse"));
|
||||
return Task::none();
|
||||
return Err(t(self.ui_locale, "engine.previewPortInUse"));
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error.to_string());
|
||||
return Task::none();
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5735,19 +5885,111 @@ impl BdsApp {
|
||||
let _ = &session.handle;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn preview_url_for_post(&mut self, post_id: &str) -> Result<String, String> {
|
||||
if let Err(error) = self.persist_post_editor_state(post_id) {
|
||||
return Err(error);
|
||||
}
|
||||
self.ensure_preview_server()?;
|
||||
|
||||
let language = self
|
||||
.post_editors
|
||||
.get(&active_id)
|
||||
.get(post_id)
|
||||
.map(|editor| editor.active_language.clone())
|
||||
.filter(|language| !language.is_empty())
|
||||
.unwrap_or_else(|| self.content_language.clone());
|
||||
let url = format!(
|
||||
"http://{}:{}/__draft/{}?language={}",
|
||||
engine::preview::PREVIEW_HOST,
|
||||
engine::preview::PREVIEW_PORT,
|
||||
active_id,
|
||||
language
|
||||
);
|
||||
|
||||
Ok(draft_preview_url(post_id, &language))
|
||||
}
|
||||
|
||||
fn active_post_uses_embedded_preview(&self) -> bool {
|
||||
self.active_tab
|
||||
.as_ref()
|
||||
.and_then(|tab_id| self.post_editors.get(tab_id))
|
||||
.map(|editor| editor.editor_mode == "preview")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn hide_embedded_preview(&self) {
|
||||
if let Some(preview) = &self.embedded_preview {
|
||||
preview.controller.set_visible(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_embedded_preview_for_active_post(&mut self) -> Task<Message> {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
self.hide_embedded_preview();
|
||||
return Task::none();
|
||||
};
|
||||
let Some(editor) = self.post_editors.get(&active_id) else {
|
||||
self.hide_embedded_preview();
|
||||
return Task::none();
|
||||
};
|
||||
if editor.editor_mode != "preview" {
|
||||
self.hide_embedded_preview();
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let url = match self.preview_url_for_post(&active_id) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(preview) = &mut self.embedded_preview {
|
||||
preview.current_url = Some(url.clone());
|
||||
if preview.controller.is_active() {
|
||||
preview.controller.navigate(&url);
|
||||
preview.controller.set_visible(true);
|
||||
return Task::none();
|
||||
}
|
||||
} else {
|
||||
self.embedded_preview = Some(EmbeddedPreviewState {
|
||||
controller: WebViewController::new(WebViewConfig::default().url(url.clone())),
|
||||
current_url: Some(url.clone()),
|
||||
});
|
||||
}
|
||||
|
||||
let Some(window_id) = self.main_window_id else {
|
||||
return window::get_oldest().map(Message::MainWindowLoaded);
|
||||
};
|
||||
|
||||
if let Some(preview) = &mut self.embedded_preview {
|
||||
if !preview.controller.is_active() {
|
||||
preview.controller = WebViewController::new(WebViewConfig::default().url(url));
|
||||
return preview
|
||||
.controller
|
||||
.create_task(window_id, Message::EmbeddedPreviewReady);
|
||||
}
|
||||
}
|
||||
|
||||
Task::none()
|
||||
}
|
||||
|
||||
fn preview_active_post(&mut self) -> Task<Message> {
|
||||
let Some(active_id) = self.active_tab.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !self
|
||||
.tabs
|
||||
.iter()
|
||||
.any(|tab| tab.id == active_id && tab.tab_type == TabType::Post)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let url = match self.preview_url_for_post(&active_id) {
|
||||
Ok(url) => url,
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = open::that(&url) {
|
||||
self.notify(ToastLevel::Error, &error.to_string());
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod webview;
|
||||
|
||||
317
crates/bds-ui/src/components/webview.rs
Normal file
317
crates/bds-ui/src/components/webview.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use iced::event;
|
||||
use iced::advanced::layout::{self, Node};
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::widget::Tree;
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget};
|
||||
use iced::mouse;
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Task, window};
|
||||
use wry::dpi::{LogicalPosition, LogicalSize};
|
||||
use wry::{Rect, WebView, WebViewBuilder};
|
||||
|
||||
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
thread_local! {
|
||||
static STAGED: RefCell<HashMap<u64, WebView>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub enum Content {
|
||||
Url(String),
|
||||
Html(String),
|
||||
}
|
||||
|
||||
impl Default for Content {
|
||||
fn default() -> Self {
|
||||
Self::Url(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WebViewConfig {
|
||||
content: Content,
|
||||
transparent: bool,
|
||||
devtools: bool,
|
||||
}
|
||||
|
||||
impl WebViewConfig {
|
||||
pub fn url(mut self, url: impl Into<String>) -> Self {
|
||||
self.content = Content::Url(url.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn html(mut self, html: impl Into<String>) -> Self {
|
||||
self.content = Content::Html(html.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn transparent(mut self, transparent: bool) -> Self {
|
||||
self.transparent = transparent;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn devtools(mut self, devtools: bool) -> Self {
|
||||
self.devtools = devtools;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedState {
|
||||
webview: Option<WebView>,
|
||||
last_bounds: Option<Rectangle>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BoundsSender(Rc<RefCell<SharedState>>);
|
||||
|
||||
impl BoundsSender {
|
||||
pub(crate) fn apply(&self, bounds: Rectangle) {
|
||||
let mut state = self.0.borrow_mut();
|
||||
state.last_bounds = Some(bounds);
|
||||
if let Some(webview) = &state.webview {
|
||||
let rect = Rect {
|
||||
position: LogicalPosition::new(bounds.x as f64, bounds.y as f64).into(),
|
||||
size: LogicalSize::new(bounds.width as f64, bounds.height as f64).into(),
|
||||
};
|
||||
let _ = webview.set_bounds(rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refocus_parent(&self) {
|
||||
let state = self.0.borrow();
|
||||
if let Some(webview) = &state.webview {
|
||||
let _ = webview.focus_parent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WebViewController {
|
||||
id: u64,
|
||||
shared: Rc<RefCell<SharedState>>,
|
||||
config: WebViewConfig,
|
||||
}
|
||||
|
||||
impl WebViewController {
|
||||
pub fn new(config: WebViewConfig) -> Self {
|
||||
Self {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
shared: Rc::new(RefCell::new(SharedState {
|
||||
webview: None,
|
||||
last_bounds: None,
|
||||
})),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bounds_sender(&self) -> BoundsSender {
|
||||
BoundsSender(Rc::clone(&self.shared))
|
||||
}
|
||||
|
||||
pub fn create_task<M: Send + 'static>(
|
||||
&mut self,
|
||||
window_id: window::Id,
|
||||
on_result: fn(Result<(), String>) -> M,
|
||||
) -> Task<M> {
|
||||
let id = self.id;
|
||||
let content = std::mem::take(&mut self.config.content);
|
||||
let transparent = self.config.transparent;
|
||||
let devtools = self.config.devtools;
|
||||
|
||||
window::run_with_handle(window_id, move |handle| {
|
||||
build_webview(id, handle, content, transparent, devtools)
|
||||
})
|
||||
.map(on_result)
|
||||
}
|
||||
|
||||
pub fn take_staged(&mut self) {
|
||||
let webview = STAGED.with(|cell| cell.borrow_mut().remove(&self.id));
|
||||
let mut state = self.shared.borrow_mut();
|
||||
state.webview = webview;
|
||||
|
||||
if let (Some(webview), Some(bounds)) = (&state.webview, state.last_bounds) {
|
||||
let rect = Rect {
|
||||
position: LogicalPosition::new(bounds.x as f64, bounds.y as f64).into(),
|
||||
size: LogicalSize::new(bounds.width as f64, bounds.height as f64).into(),
|
||||
};
|
||||
let _ = webview.set_bounds(rect);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_visible(&self, visible: bool) {
|
||||
let state = self.shared.borrow();
|
||||
if let Some(webview) = &state.webview {
|
||||
let _ = webview.set_visible(visible);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigate(&self, url: &str) {
|
||||
let state = self.shared.borrow();
|
||||
if let Some(webview) = &state.webview {
|
||||
let _ = webview.load_url(url);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroy(&mut self) {
|
||||
self.shared.borrow_mut().webview = None;
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.shared.borrow().webview.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_webview(
|
||||
id: u64,
|
||||
handle: window::raw_window_handle::WindowHandle<'_>,
|
||||
content: Content,
|
||||
transparent: bool,
|
||||
devtools: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut builder = WebViewBuilder::new()
|
||||
.with_transparent(transparent)
|
||||
.with_devtools(devtools)
|
||||
.with_focused(false);
|
||||
|
||||
builder = match content {
|
||||
Content::Html(html) => builder.with_html(html),
|
||||
Content::Url(url) => builder.with_url(url),
|
||||
};
|
||||
|
||||
let webview = builder
|
||||
.build_as_child(&handle)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
STAGED.with(|cell| {
|
||||
cell.borrow_mut().insert(id, webview);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PlaceholderState {
|
||||
last_bounds: Option<Rectangle>,
|
||||
}
|
||||
|
||||
pub struct WebViewPlaceholder<Message> {
|
||||
width: Length,
|
||||
height: Length,
|
||||
bounds_tx: Option<BoundsSender>,
|
||||
_message: std::marker::PhantomData<Message>,
|
||||
}
|
||||
|
||||
impl<Message> WebViewPlaceholder<Message> {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
width: Length::Fill,
|
||||
height: Length::Fill,
|
||||
bounds_tx: None,
|
||||
_message: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||
self.width = width.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
||||
self.height = height.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn bounds_sender(mut self, sender: BoundsSender) -> Self {
|
||||
self.bounds_tx = Some(sender);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message> Default for WebViewPlaceholder<Message> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer> for WebViewPlaceholder<Message>
|
||||
where
|
||||
Renderer: renderer::Renderer,
|
||||
{
|
||||
fn tag(&self) -> iced::advanced::widget::tree::Tag {
|
||||
iced::advanced::widget::tree::Tag::of::<PlaceholderState>()
|
||||
}
|
||||
|
||||
fn state(&self) -> iced::advanced::widget::tree::State {
|
||||
iced::advanced::widget::tree::State::new(PlaceholderState::default())
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
Size::new(self.width, self.height)
|
||||
}
|
||||
|
||||
fn layout(&self, _tree: &mut Tree, _renderer: &Renderer, limits: &layout::Limits) -> Node {
|
||||
Node::new(limits.resolve(self.width, self.height, Size::ZERO))
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
_tree: &Tree,
|
||||
_renderer: &mut Renderer,
|
||||
_theme: &Theme,
|
||||
_style: &renderer::Style,
|
||||
_layout: Layout<'_>,
|
||||
_cursor: mouse::Cursor,
|
||||
_viewport: &Rectangle,
|
||||
) {
|
||||
}
|
||||
|
||||
fn on_event(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
_renderer: &Renderer,
|
||||
_clipboard: &mut dyn Clipboard,
|
||||
_shell: &mut Shell<'_, Message>,
|
||||
_viewport: &Rectangle,
|
||||
) -> event::Status {
|
||||
let Some(tx) = &self.bounds_tx else {
|
||||
return event::Status::Ignored;
|
||||
};
|
||||
|
||||
let state = tree.state.downcast_mut::<PlaceholderState>();
|
||||
let bounds = layout.bounds();
|
||||
|
||||
if state.last_bounds.as_ref() != Some(&bounds) {
|
||||
state.last_bounds = Some(bounds);
|
||||
tx.apply(bounds);
|
||||
}
|
||||
|
||||
if let Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) = event
|
||||
&& !cursor.is_over(bounds)
|
||||
{
|
||||
tx.refocus_parent();
|
||||
}
|
||||
|
||||
event::Status::Ignored
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer> From<WebViewPlaceholder<Message>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: 'a,
|
||||
Theme: 'a,
|
||||
Renderer: renderer::Renderer + 'a,
|
||||
{
|
||||
fn from(placeholder: WebViewPlaceholder<Message>) -> Self {
|
||||
Self::new(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webview<Message>(controller: &WebViewController) -> WebViewPlaceholder<Message> {
|
||||
WebViewPlaceholder::new().bounds_sender(controller.bounds_sender())
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use iced::widget::text::{Shaping, Wrapping};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Column, Space};
|
||||
use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Column, Space};
|
||||
use iced::{Color, Element, Length, Theme};
|
||||
use iced_widget::markdown;
|
||||
|
||||
@@ -20,7 +21,7 @@ use crate::views::status_bar;
|
||||
pub struct TranslationFlag {
|
||||
pub language: String,
|
||||
pub flag_emoji: String,
|
||||
pub status: String, // "draft" | "published" | "missing"
|
||||
pub status: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
@@ -46,10 +47,17 @@ pub struct ResolvedPostLink {
|
||||
pub struct LinkedMediaItem {
|
||||
pub media_id: String,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
pub is_image: bool,
|
||||
pub sort_order: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum PreviewBlock {
|
||||
Markdown(Vec<markdown::Item>),
|
||||
Image { alt: String, target: String },
|
||||
}
|
||||
|
||||
/// State for an open post editor.
|
||||
pub struct PostEditorState {
|
||||
pub post_id: String,
|
||||
@@ -59,6 +67,7 @@ pub struct PostEditorState {
|
||||
pub content: String,
|
||||
pub editor_buffer: RefCell<EditorBuffer>,
|
||||
pub preview_items: Vec<markdown::Item>,
|
||||
preview_blocks: Vec<PreviewBlock>,
|
||||
pub tags: Vec<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub author: String,
|
||||
@@ -77,22 +86,13 @@ pub struct PostEditorState {
|
||||
pub quick_actions_open: bool,
|
||||
pub tags_input: String,
|
||||
pub categories_input: String,
|
||||
// ── Translation flags ──
|
||||
/// Currently-displayed language (canonical or translation).
|
||||
pub active_language: String,
|
||||
/// The post's own language (canonical).
|
||||
pub canonical_language: String,
|
||||
/// All blog languages from project metadata.
|
||||
pub blog_languages: Vec<String>,
|
||||
/// Saved canonical title/excerpt/content when viewing a translation.
|
||||
pub saved_canonical: Option<TranslationDraft>,
|
||||
/// Translation drafts keyed by language code.
|
||||
pub translation_drafts: HashMap<String, TranslationDraft>,
|
||||
/// Outgoing links from this post to other posts.
|
||||
pub outlinks: Vec<ResolvedPostLink>,
|
||||
/// Incoming links from other posts to this post.
|
||||
pub backlinks: Vec<ResolvedPostLink>,
|
||||
/// Media linked to this post.
|
||||
pub linked_media: Vec<LinkedMediaItem>,
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ impl Clone for PostEditorState {
|
||||
content: self.content.clone(),
|
||||
editor_buffer: RefCell::new(EditorBuffer::new(&self.content)),
|
||||
preview_items: self.preview_items.clone(),
|
||||
preview_blocks: self.preview_blocks.clone(),
|
||||
tags: self.tags.clone(),
|
||||
categories: self.categories.clone(),
|
||||
author: self.author.clone(),
|
||||
@@ -160,8 +161,9 @@ impl PostEditorState {
|
||||
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::<Vec<_>>();
|
||||
let preview_document = preview_markdown_document_parts(&title, &excerpt, &content);
|
||||
let preview_items = markdown::parse(&preview_document).collect::<Vec<_>>();
|
||||
let preview_blocks = build_preview_blocks_from_document(&preview_document, &preview_items);
|
||||
|
||||
let mut translation_drafts = HashMap::new();
|
||||
for tr in translations {
|
||||
@@ -184,6 +186,7 @@ impl PostEditorState {
|
||||
content: content.clone(),
|
||||
editor_buffer: RefCell::new(EditorBuffer::new(&content)),
|
||||
preview_items,
|
||||
preview_blocks,
|
||||
tags: post.tags.clone(),
|
||||
categories: post.categories.clone(),
|
||||
author: post.author.clone().unwrap_or_default(),
|
||||
@@ -235,19 +238,17 @@ impl PostEditorState {
|
||||
}
|
||||
|
||||
pub fn refresh_preview_items(&mut self) {
|
||||
self.preview_items = markdown::parse(&preview_markdown_document(self)).collect();
|
||||
let preview_document = preview_markdown_document(self);
|
||||
self.preview_items = markdown::parse(&preview_document).collect();
|
||||
self.preview_blocks = build_preview_blocks_from_document(&preview_document, &self.preview_items);
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if target_lang == self.active_language {
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current fields
|
||||
if self.active_language == self.canonical_language {
|
||||
// Switching away from canonical — stash canonical fields
|
||||
self.saved_canonical = Some(TranslationDraft {
|
||||
title: self.title.clone(),
|
||||
excerpt: self.excerpt.clone(),
|
||||
@@ -256,7 +257,6 @@ impl PostEditorState {
|
||||
is_dirty: self.is_dirty,
|
||||
});
|
||||
} else {
|
||||
// Switching away from a translation — save to drafts
|
||||
self.translation_drafts.insert(
|
||||
self.active_language.clone(),
|
||||
TranslationDraft {
|
||||
@@ -269,9 +269,7 @@ impl PostEditorState {
|
||||
);
|
||||
}
|
||||
|
||||
// Load target fields
|
||||
if target_lang == self.canonical_language {
|
||||
// Restore canonical
|
||||
if let Some(saved) = self.saved_canonical.take() {
|
||||
self.title = saved.title;
|
||||
self.excerpt = saved.excerpt;
|
||||
@@ -281,14 +279,12 @@ impl PostEditorState {
|
||||
self.is_dirty = saved.is_dirty;
|
||||
}
|
||||
} else if let Some(draft) = self.translation_drafts.get(target_lang) {
|
||||
// Load existing translation
|
||||
self.title = draft.title.clone();
|
||||
self.excerpt = draft.excerpt.clone();
|
||||
self.content = draft.content.clone();
|
||||
self.editor_buffer = RefCell::new(EditorBuffer::new(&draft.content));
|
||||
self.is_dirty = draft.is_dirty;
|
||||
} else {
|
||||
// No translation yet — blank fields
|
||||
self.title = String::new();
|
||||
self.excerpt = String::new();
|
||||
self.content = String::new();
|
||||
@@ -300,7 +296,6 @@ impl PostEditorState {
|
||||
self.refresh_preview_items();
|
||||
}
|
||||
|
||||
/// Build the translation flags list for the view.
|
||||
pub fn translation_flags(&self) -> Vec<TranslationFlag> {
|
||||
let mut flags = Vec::new();
|
||||
|
||||
@@ -402,6 +397,7 @@ pub fn view<'a>(
|
||||
state: &'a PostEditorState,
|
||||
locale: UiLocale,
|
||||
word_wrap: bool,
|
||||
preview_widget: Option<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let on_translation = state.active_language != state.canonical_language;
|
||||
|
||||
@@ -859,19 +855,17 @@ pub fn view<'a>(
|
||||
],
|
||||
);
|
||||
let editor_widget: Element<'a, Message> = if state.editor_mode == "preview" {
|
||||
scrollable(
|
||||
preview_widget.unwrap_or_else(|| {
|
||||
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())),
|
||||
text(t(locale, "tabBar.loading"))
|
||||
.size(14)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.padding(16),
|
||||
)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
})
|
||||
} else {
|
||||
CodeEditor::new(&state.editor_buffer, highlighter(), "md")
|
||||
.word_wrap(word_wrap)
|
||||
@@ -1051,6 +1045,133 @@ fn preview_markdown_document_parts(title: &str, excerpt: &str, content: &str) ->
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
fn build_preview_blocks_from_document(
|
||||
document: &str,
|
||||
fallback_items: &[markdown::Item],
|
||||
) -> Vec<PreviewBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
let mut markdown_buffer = Vec::new();
|
||||
|
||||
for line in document.lines() {
|
||||
if let Some((alt, target)) = parse_standalone_markdown_image(line) {
|
||||
flush_markdown_buffer(&mut blocks, &mut markdown_buffer);
|
||||
blocks.push(PreviewBlock::Image { alt, target });
|
||||
} else {
|
||||
markdown_buffer.push(line.to_string());
|
||||
}
|
||||
}
|
||||
flush_markdown_buffer(&mut blocks, &mut markdown_buffer);
|
||||
|
||||
if blocks.is_empty() {
|
||||
blocks.push(PreviewBlock::Markdown(fallback_items.to_vec()));
|
||||
}
|
||||
|
||||
blocks
|
||||
}
|
||||
|
||||
fn flush_markdown_buffer(blocks: &mut Vec<PreviewBlock>, markdown_buffer: &mut Vec<String>) {
|
||||
let markdown_text = markdown_buffer.join("\n");
|
||||
if !markdown_text.trim().is_empty() {
|
||||
blocks.push(PreviewBlock::Markdown(
|
||||
markdown::parse(&markdown_text).collect::<Vec<_>>(),
|
||||
));
|
||||
}
|
||||
markdown_buffer.clear();
|
||||
}
|
||||
|
||||
fn parse_standalone_markdown_image(line: &str) -> Option<(String, String)> {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("?;
|
||||
if !trimmed.ends_with(')') {
|
||||
return None;
|
||||
}
|
||||
let alt = trimmed[2..alt_end].to_string();
|
||||
let target = trimmed[alt_end + 2..trimmed.len() - 1].trim().to_string();
|
||||
if target.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((alt, target))
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_block_view<'a>(
|
||||
block: &'a PreviewBlock,
|
||||
state: &'a PostEditorState,
|
||||
data_dir: Option<&'a Path>,
|
||||
) -> Element<'a, Message> {
|
||||
match block {
|
||||
PreviewBlock::Markdown(items) => markdown::view(
|
||||
items,
|
||||
markdown::Settings::with_text_size(15),
|
||||
markdown::Style::from_palette(Theme::TokyoNightStorm.palette()),
|
||||
)
|
||||
.map(|url: markdown::Url| Message::UrlOpenRequested(url.to_string()))
|
||||
.into(),
|
||||
PreviewBlock::Image { alt, target } => preview_image_block(state, data_dir, alt, target),
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_image_block<'a>(
|
||||
state: &'a PostEditorState,
|
||||
data_dir: Option<&'a Path>,
|
||||
alt: &str,
|
||||
target: &str,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(path) = resolve_preview_image_path(state, data_dir, target) else {
|
||||
return text(format!(""))
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.70, 0.50, 0.50))
|
||||
.shaping(Shaping::Advanced)
|
||||
.into();
|
||||
};
|
||||
|
||||
let mut content = column![
|
||||
container(
|
||||
image(path)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Shrink)
|
||||
)
|
||||
.width(Length::Fill)
|
||||
]
|
||||
.spacing(6);
|
||||
if !alt.is_empty() {
|
||||
content = content.push(
|
||||
text(alt.to_string())
|
||||
.size(12)
|
||||
.color(Color::from_rgb(0.62, 0.66, 0.74))
|
||||
.shaping(Shaping::Advanced),
|
||||
);
|
||||
}
|
||||
|
||||
content.into()
|
||||
}
|
||||
|
||||
fn resolve_preview_image_path(
|
||||
state: &PostEditorState,
|
||||
data_dir: Option<&Path>,
|
||||
target: &str,
|
||||
) -> Option<String> {
|
||||
let data_dir = data_dir?;
|
||||
if let Some(media_id) = target.strip_prefix("bds-media://") {
|
||||
return state
|
||||
.linked_media
|
||||
.iter()
|
||||
.find(|media| media.media_id == media_id && media.is_image)
|
||||
.map(|media| data_dir.join(&media.file_path).to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
let relative = target.trim_start_matches('/');
|
||||
let candidate = data_dir.join(relative);
|
||||
if candidate.exists() {
|
||||
Some(candidate.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_editor_mode(mode: &str) -> String {
|
||||
match mode {
|
||||
"preview" => "preview".to_string(),
|
||||
@@ -1117,7 +1238,6 @@ fn flag_inactive_style(_theme: &Theme, status: button::Status) -> button::Style
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_state() -> PostEditorState {
|
||||
let post = Post {
|
||||
id: "post-1".to_string(),
|
||||
@@ -1187,4 +1307,5 @@ mod tests {
|
||||
state.set_editor_mode("preview");
|
||||
assert_eq!(state.editor_mode, "preview");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn view<'a>(
|
||||
active_modal: Option<modal::ModalState>,
|
||||
// Data directory (for thumbnail paths)
|
||||
data_dir: Option<&'a Path>,
|
||||
post_preview_widget: Option<Element<'a, Message>>,
|
||||
// Editor states
|
||||
post_editors: &'a HashMap<String, PostEditorState>,
|
||||
media_editors: &'a HashMap<String, MediaEditorState>,
|
||||
@@ -122,6 +123,7 @@ pub fn view<'a>(
|
||||
active_tab,
|
||||
locale,
|
||||
data_dir,
|
||||
post_preview_widget,
|
||||
post_editors,
|
||||
media_editors,
|
||||
template_editors,
|
||||
@@ -343,6 +345,7 @@ fn route_content_area<'a>(
|
||||
active_tab: Option<&'a str>,
|
||||
locale: UiLocale,
|
||||
data_dir: Option<&'a Path>,
|
||||
post_preview_widget: Option<Element<'a, Message>>,
|
||||
post_editors: &'a HashMap<String, PostEditorState>,
|
||||
media_editors: &'a HashMap<String, MediaEditorState>,
|
||||
template_editors: &'a HashMap<String, TemplateEditorState>,
|
||||
@@ -368,7 +371,7 @@ fn route_content_area<'a>(
|
||||
ContentRoute::Post(tab_id) => {
|
||||
if let Some(state) = post_editors.get(tab_id) {
|
||||
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
||||
post_editor::view(state, locale, wrap)
|
||||
post_editor::view(state, locale, wrap, post_preview_widget)
|
||||
} else {
|
||||
loading_view(locale)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user