fix: hopefully preview works now

This commit is contained in:
2026-04-10 20:37:11 +02:00
parent 39ee0dc64e
commit cce4b96efa
7 changed files with 1532 additions and 110 deletions

793
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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!("![](bds-media://{})", 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());
}

View File

@@ -1 +1,2 @@
pub mod inputs;
pub mod webview;

View 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())
}

View File

@@ -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("![") {
return None;
}
let alt_end = trimmed.find("](")?;
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!("![{alt}]({target})"))
.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");
}
}

View File

@@ -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)
}