Preserve cursor position for modal insertions
This commit is contained in:
@@ -9,7 +9,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
|||||||
## Available Features
|
## Available Features
|
||||||
|
|
||||||
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Its shared task UI keeps queued work off blocking workers, shows every active local or remote task plus recent history, and supports cooperative cancellation without oversubscribing background work.
|
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, direct Preferences navigation, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Its shared task UI keeps queued work off blocking workers, shows every active local or remote task plus recent history, and supports cooperative cancellation without oversubscribing background work.
|
||||||
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
|
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, cursor-preserving link and media insertion, live link/backlink graphs, media, and batch gallery-image import.
|
||||||
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||||
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
- Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync.
|
||||||
|
|||||||
@@ -6897,14 +6897,15 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
fn insert_link_modal(&mut self, post_id: &str) -> Task<Message> {
|
||||||
let state = match self.post_editors.get(post_id) {
|
let (title, insertion_point) = match self.post_editors.get(post_id) {
|
||||||
Some(s) => s.clone(),
|
Some(state) => (state.title.clone(), state.insertion_point()),
|
||||||
None => return Task::none(),
|
None => return Task::none(),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
||||||
post_id: post_id.to_string(),
|
post_id: post_id.to_string(),
|
||||||
title: state.title,
|
title,
|
||||||
|
insertion_point,
|
||||||
results: self.query_post_link_results(post_id, ""),
|
results: self.query_post_link_results(post_id, ""),
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
active_tab: modal::PostInsertLinkTab::Internal,
|
active_tab: modal::PostInsertLinkTab::Internal,
|
||||||
@@ -6915,14 +6916,15 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn insert_media_modal(&mut self, post_id: &str, link_only: bool) -> Task<Message> {
|
fn insert_media_modal(&mut self, post_id: &str, link_only: bool) -> Task<Message> {
|
||||||
let state = match self.post_editors.get(post_id) {
|
let (title, insertion_point) = match self.post_editors.get(post_id) {
|
||||||
Some(s) => s.clone(),
|
Some(state) => (state.title.clone(), state.insertion_point()),
|
||||||
None => return Task::none(),
|
None => return Task::none(),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.active_modal = Some(modal::ModalState::InsertMedia {
|
self.active_modal = Some(modal::ModalState::InsertMedia {
|
||||||
post_id: post_id.to_string(),
|
post_id: post_id.to_string(),
|
||||||
title: state.title,
|
title,
|
||||||
|
insertion_point,
|
||||||
media_list: self.query_post_insert_media_results(""),
|
media_list: self.query_post_insert_media_results(""),
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
link_only,
|
link_only,
|
||||||
@@ -7076,10 +7078,27 @@ impl BdsApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn insert_markdown_into_post(&mut self, post_id: &str, markdown: &str) -> Task<Message> {
|
fn insert_markdown_into_post(&mut self, post_id: &str, markdown: &str) -> Task<Message> {
|
||||||
|
let insertion_point = match self.active_modal.as_ref() {
|
||||||
|
Some(modal::ModalState::PostInsertLink {
|
||||||
|
post_id: modal_post_id,
|
||||||
|
insertion_point,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
| Some(modal::ModalState::InsertMedia {
|
||||||
|
post_id: modal_post_id,
|
||||||
|
insertion_point,
|
||||||
|
..
|
||||||
|
}) if modal_post_id == post_id => Some(*insertion_point),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let Some(state) = self.post_editors.get_mut(post_id) else {
|
let Some(state) = self.post_editors.get_mut(post_id) else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
state.insert_markdown_at_cursor(markdown);
|
if let Some(insertion_point) = insertion_point {
|
||||||
|
state.insert_markdown_at(markdown, insertion_point);
|
||||||
|
} else {
|
||||||
|
state.insert_markdown_at_cursor(markdown);
|
||||||
|
}
|
||||||
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == post_id) {
|
if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == post_id) {
|
||||||
tab.is_dirty = true;
|
tab.is_dirty = true;
|
||||||
}
|
}
|
||||||
@@ -7228,6 +7247,7 @@ impl BdsApp {
|
|||||||
let Some(modal::ModalState::PostInsertLink {
|
let Some(modal::ModalState::PostInsertLink {
|
||||||
post_id,
|
post_id,
|
||||||
title,
|
title,
|
||||||
|
insertion_point,
|
||||||
search_query: current_query,
|
search_query: current_query,
|
||||||
active_tab: current_tab,
|
active_tab: current_tab,
|
||||||
external_url: current_url,
|
external_url: current_url,
|
||||||
@@ -7246,6 +7266,7 @@ impl BdsApp {
|
|||||||
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
self.active_modal = Some(modal::ModalState::PostInsertLink {
|
||||||
post_id: post_id.clone(),
|
post_id: post_id.clone(),
|
||||||
title,
|
title,
|
||||||
|
insertion_point,
|
||||||
results: self.query_post_link_results(&post_id, &next_query),
|
results: self.query_post_link_results(&post_id, &next_query),
|
||||||
search_query: next_query,
|
search_query: next_query,
|
||||||
active_tab: next_tab,
|
active_tab: next_tab,
|
||||||
@@ -7258,6 +7279,7 @@ impl BdsApp {
|
|||||||
let Some(modal::ModalState::InsertMedia {
|
let Some(modal::ModalState::InsertMedia {
|
||||||
post_id,
|
post_id,
|
||||||
title,
|
title,
|
||||||
|
insertion_point,
|
||||||
link_only,
|
link_only,
|
||||||
..
|
..
|
||||||
}) = self.active_modal.clone()
|
}) = self.active_modal.clone()
|
||||||
@@ -7268,6 +7290,7 @@ impl BdsApp {
|
|||||||
self.active_modal = Some(modal::ModalState::InsertMedia {
|
self.active_modal = Some(modal::ModalState::InsertMedia {
|
||||||
post_id,
|
post_id,
|
||||||
title,
|
title,
|
||||||
|
insertion_point,
|
||||||
media_list: self.query_post_insert_media_results(&search_query),
|
media_list: self.query_post_insert_media_results(&search_query),
|
||||||
search_query,
|
search_query,
|
||||||
link_only,
|
link_only,
|
||||||
@@ -13542,6 +13565,31 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_modal_inserts_at_the_cursor_saved_before_focus_changes() {
|
||||||
|
let (mut app, post_id, _tmp) = auto_translation_test_app(&[]);
|
||||||
|
app.post_editors
|
||||||
|
.get_mut(&post_id)
|
||||||
|
.unwrap()
|
||||||
|
.editor_buffer
|
||||||
|
.borrow_mut()
|
||||||
|
.set_cursor(0, 2);
|
||||||
|
|
||||||
|
let _ = app.insert_link_modal(&post_id);
|
||||||
|
app.post_editors
|
||||||
|
.get_mut(&post_id)
|
||||||
|
.unwrap()
|
||||||
|
.editor_buffer
|
||||||
|
.borrow_mut()
|
||||||
|
.set_cursor(0, 0);
|
||||||
|
let _ = app.insert_markdown_into_post(&post_id, "[Link](/target)");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.post_editors.get(&post_id).unwrap().content,
|
||||||
|
"Bo[Link](/target)dy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_editor_loads_existing_project_tags_for_partial_suggestions() {
|
fn post_editor_loads_existing_project_tags_for_partial_suggestions() {
|
||||||
let (db, project, tmp) = setup();
|
let (db, project, tmp) = setup();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use bds_core::util::paths::thumbnail_path;
|
|||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::inputs;
|
use crate::components::inputs;
|
||||||
use crate::i18n::t;
|
use crate::i18n::t;
|
||||||
use crate::views::post_editor::PostEditorMsg;
|
use crate::views::post_editor::{EditorInsertionPoint, PostEditorMsg};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InsertLinkResult {
|
pub struct InsertLinkResult {
|
||||||
@@ -77,6 +77,7 @@ pub enum ModalState {
|
|||||||
PostInsertLink {
|
PostInsertLink {
|
||||||
post_id: String,
|
post_id: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
insertion_point: EditorInsertionPoint,
|
||||||
results: Vec<InsertLinkResult>,
|
results: Vec<InsertLinkResult>,
|
||||||
search_query: String,
|
search_query: String,
|
||||||
active_tab: PostInsertLinkTab,
|
active_tab: PostInsertLinkTab,
|
||||||
@@ -87,6 +88,7 @@ pub enum ModalState {
|
|||||||
InsertMedia {
|
InsertMedia {
|
||||||
post_id: String,
|
post_id: String,
|
||||||
title: String,
|
title: String,
|
||||||
|
insertion_point: EditorInsertionPoint,
|
||||||
media_list: Vec<bds_core::model::Media>,
|
media_list: Vec<bds_core::model::Media>,
|
||||||
search_query: String,
|
search_query: String,
|
||||||
link_only: bool,
|
link_only: bool,
|
||||||
@@ -598,6 +600,7 @@ pub fn view(
|
|||||||
ModalState::PostInsertLink {
|
ModalState::PostInsertLink {
|
||||||
post_id: _post_id,
|
post_id: _post_id,
|
||||||
title: _title,
|
title: _title,
|
||||||
|
insertion_point: _,
|
||||||
results,
|
results,
|
||||||
search_query,
|
search_query,
|
||||||
active_tab,
|
active_tab,
|
||||||
@@ -820,6 +823,7 @@ pub fn view(
|
|||||||
ModalState::InsertMedia {
|
ModalState::InsertMedia {
|
||||||
post_id: _post_id,
|
post_id: _post_id,
|
||||||
title: _title,
|
title: _title,
|
||||||
|
insertion_point: _,
|
||||||
media_list,
|
media_list,
|
||||||
search_query,
|
search_query,
|
||||||
link_only: _,
|
link_only: _,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use iced::{Color, Element, Length, Theme};
|
|||||||
|
|
||||||
use bds_core::i18n::{self, UiLocale};
|
use bds_core::i18n::{self, UiLocale};
|
||||||
use bds_core::model::{Post, PostStatus, PostTranslation};
|
use bds_core::model::{Post, PostStatus, PostTranslation};
|
||||||
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, highlighter};
|
use bds_editor::{CodeEditor, EditorBuffer, EditorMessage, Selection, highlighter};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::components::{inputs, popover};
|
use crate::components::{inputs, popover};
|
||||||
@@ -52,6 +52,12 @@ pub struct LinkedMediaItem {
|
|||||||
pub sort_order: i32,
|
pub sort_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct EditorInsertionPoint {
|
||||||
|
cursor: (usize, usize),
|
||||||
|
selection: Option<Selection>,
|
||||||
|
}
|
||||||
|
|
||||||
/// State for an open post editor.
|
/// State for an open post editor.
|
||||||
pub struct PostEditorState {
|
pub struct PostEditorState {
|
||||||
pub post_id: String,
|
pub post_id: String,
|
||||||
@@ -269,8 +275,30 @@ impl PostEditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
|
pub fn insert_markdown_at_cursor(&mut self, markdown: &str) {
|
||||||
|
self.insert_markdown_at(markdown, self.insertion_point());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insertion_point(&self) -> EditorInsertionPoint {
|
||||||
|
let buffer = self.editor_buffer.borrow();
|
||||||
|
EditorInsertionPoint {
|
||||||
|
cursor: buffer.cursor(),
|
||||||
|
selection: buffer.selection().copied(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_markdown_at(&mut self, markdown: &str, point: EditorInsertionPoint) {
|
||||||
let new_content = {
|
let new_content = {
|
||||||
let mut buffer = self.editor_buffer.borrow_mut();
|
let mut buffer = self.editor_buffer.borrow_mut();
|
||||||
|
buffer.clear_selection();
|
||||||
|
buffer.set_cursor(point.cursor.0, point.cursor.1);
|
||||||
|
if let Some(selection) = point.selection {
|
||||||
|
buffer.set_selection(
|
||||||
|
selection.anchor_line,
|
||||||
|
selection.anchor_col,
|
||||||
|
selection.head_line,
|
||||||
|
selection.head_col,
|
||||||
|
);
|
||||||
|
}
|
||||||
buffer.insert(markdown);
|
buffer.insert(markdown);
|
||||||
buffer.text()
|
buffer.text()
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user