From 7ba2699b68262b65d1852b0d146f4400d04d5fb4 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 22:23:32 +0200 Subject: [PATCH] Restore native text editing commands --- crates/bds-ui/src/app.rs | 173 +++++++--- crates/bds-ui/src/components/mod.rs | 1 + crates/bds-ui/src/components/native_edit.rs | 363 ++++++++++++++++++++ crates/bds-ui/src/platform/menu.rs | 76 +++- locales/ui/de.ftl | 6 + locales/ui/en.ftl | 6 + locales/ui/es.ftl | 6 + locales/ui/fr.ftl | 6 + locales/ui/it.ftl | 6 + 9 files changed, 579 insertions(+), 64 deletions(-) create mode 100644 crates/bds-ui/src/components/native_edit.rs diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 1aef216..de0a44a 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -18,6 +18,7 @@ use bds_core::model::{ SshMode, Template, }; +use crate::components::native_edit; use crate::components::webview::{self, WebViewConfig, WebViewController}; use crate::i18n::{t, tw}; use crate::platform::menu::{self, MenuAction, MenuRegistry}; @@ -920,6 +921,7 @@ pub struct BdsApp { // Platform _menu_bar: Option, menu_registry: MenuRegistry, + native_edit_commands: native_edit::EditCommandQueue, // i18n ui_locale: UiLocale, @@ -1108,6 +1110,7 @@ impl BdsApp { site_generation_workflows: HashMap::new(), _menu_bar: Some(menu_bar), menu_registry: registry, + native_edit_commands: native_edit::command_queue(), ui_locale: locale, content_language: "en".to_string(), blog_languages: Vec::new(), @@ -1196,6 +1199,7 @@ impl BdsApp { site_generation_workflows: HashMap::new(), _menu_bar: None, menu_registry: MenuRegistry::empty(), + native_edit_commands: native_edit::command_queue(), ui_locale: UiLocale::En, content_language: "en".to_string(), blog_languages: Vec::new(), @@ -3341,62 +3345,66 @@ impl BdsApp { None }; - workspace::view( - self.sidebar_view, - self.sidebar_visible, - self.sidebar_width, - &self.tabs, - self.active_tab.as_deref(), - self.panel_visible, - self.panel_tab, - &self.task_snapshots, - &self.collapsed_task_groups, - &self.output_entries, - &self.sidebar_posts, - &self.sidebar_media, - &self.sidebar_scripts, - &self.sidebar_templates, - &self.sidebar_imports, - &self.chat_conversations, - active_post_filter, - &self.media_filter, - &self.sidebar_media_thumbs, - self.sidebar_posts_has_more, - self.sidebar_media_has_more, - active_name, - &self.projects, - self.active_project.as_ref().map(|p| p.id.as_str()), - self.post_count, - self.media_count, - self.offline_mode, - self.locale_dropdown_open, - self.project_dropdown_open, - &self.theme_badge, - self.ui_locale, - &self.toasts, - self.active_modal.clone(), - self.data_dir.as_deref(), - post_preview_widget, - &self.post_editors, - &self.media_editors, - &self.template_editors, - &self.script_editors, - &self.import_editors, - &self.chat_editors, - self.tags_view_state.as_ref(), - self.settings_state.as_ref(), - self.dashboard_state.as_ref(), - &self.site_validation_state, - &self.duplicates_state, - &self.guide_documentation, - &self.api_documentation, - &self.metadata_diff_state, - &self.menu_editor_state, - &self.translation_validation_state, - &self.git_state, - &self.git_diffs, - &self.git_file_history, + native_edit::native_edit( + workspace::view( + self.sidebar_view, + self.sidebar_visible, + self.sidebar_width, + &self.tabs, + self.active_tab.as_deref(), + self.panel_visible, + self.panel_tab, + &self.task_snapshots, + &self.collapsed_task_groups, + &self.output_entries, + &self.sidebar_posts, + &self.sidebar_media, + &self.sidebar_scripts, + &self.sidebar_templates, + &self.sidebar_imports, + &self.chat_conversations, + active_post_filter, + &self.media_filter, + &self.sidebar_media_thumbs, + self.sidebar_posts_has_more, + self.sidebar_media_has_more, + active_name, + &self.projects, + self.active_project.as_ref().map(|p| p.id.as_str()), + self.post_count, + self.media_count, + self.offline_mode, + self.locale_dropdown_open, + self.project_dropdown_open, + &self.theme_badge, + self.ui_locale, + &self.toasts, + self.active_modal.clone(), + self.data_dir.as_deref(), + post_preview_widget, + &self.post_editors, + &self.media_editors, + &self.template_editors, + &self.script_editors, + &self.import_editors, + &self.chat_editors, + self.tags_view_state.as_ref(), + self.settings_state.as_ref(), + self.dashboard_state.as_ref(), + &self.site_validation_state, + &self.duplicates_state, + &self.guide_documentation, + &self.api_documentation, + &self.metadata_diff_state, + &self.menu_editor_state, + &self.translation_validation_state, + &self.git_state, + &self.git_diffs, + &self.git_file_history, + ), + Arc::clone(&self.native_edit_commands), ) + .into() } pub fn subscription(&self) -> Subscription { @@ -4144,6 +4152,48 @@ impl BdsApp { } MenuAction::DisconnectServer => Task::done(Message::RemoteDisconnectRequested), // Edit + MenuAction::Undo => { + native_edit::queue_command( + &self.native_edit_commands, + native_edit::EditCommand::Undo, + ); + Task::none() + } + MenuAction::Redo => { + native_edit::queue_command( + &self.native_edit_commands, + native_edit::EditCommand::Redo, + ); + Task::none() + } + MenuAction::Cut => { + native_edit::queue_command( + &self.native_edit_commands, + native_edit::EditCommand::Cut, + ); + Task::none() + } + MenuAction::Copy => { + native_edit::queue_command( + &self.native_edit_commands, + native_edit::EditCommand::Copy, + ); + Task::none() + } + MenuAction::Paste => { + native_edit::queue_command( + &self.native_edit_commands, + native_edit::EditCommand::Paste, + ); + Task::none() + } + MenuAction::SelectAll => { + native_edit::queue_command( + &self.native_edit_commands, + native_edit::EditCommand::SelectAll, + ); + Task::none() + } MenuAction::Find => { self.active_modal = Some(modal::ModalState::FindReplace { query: String::new(), @@ -9334,6 +9384,19 @@ mod tests { BdsApp::new_for_tests(db, project, tmp.path().to_path_buf()) } + #[test] + fn native_paste_menu_action_is_queued_for_the_focused_widget() { + let (db, project, tmp) = setup(); + let mut app = make_app(db, project, &tmp); + + let _ = app.dispatch_menu_action(MenuAction::Paste); + + assert_eq!( + app.native_edit_commands.lock().unwrap().pop_front(), + Some(crate::components::native_edit::EditCommand::Paste) + ); + } + fn enable_generation(tmp: &TempDir) { let mut metadata = bds_core::engine::meta::read_project_json(tmp.path()).unwrap(); metadata.public_url = Some("https://example.com".to_string()); diff --git a/crates/bds-ui/src/components/mod.rs b/crates/bds-ui/src/components/mod.rs index 5e1bd78..28ebdc2 100644 --- a/crates/bds-ui/src/components/mod.rs +++ b/crates/bds-ui/src/components/mod.rs @@ -1,2 +1,3 @@ pub mod inputs; +pub mod native_edit; pub mod webview; diff --git a/crates/bds-ui/src/components/native_edit.rs b/crates/bds-ui/src/components/native_edit.rs new file mode 100644 index 0000000..22df604 --- /dev/null +++ b/crates/bds-ui/src/components/native_edit.rs @@ -0,0 +1,363 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use iced::advanced::layout; +use iced::advanced::overlay; +use iced::advanced::renderer; +use iced::advanced::widget::{Operation, Tree, tree}; +use iced::advanced::{Clipboard, Layout, Shell, Widget}; +use iced::event; +use iced::keyboard::{self, Key, Location, Modifiers, key}; +use iced::mouse; +use iced::{Element, Event, Length, Rectangle, Size, Vector}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditCommand { + Undo, + Redo, + Cut, + Copy, + Paste, + SelectAll, +} + +pub type EditCommandQueue = Arc>>; + +pub fn command_queue() -> EditCommandQueue { + Arc::new(Mutex::new(VecDeque::new())) +} + +pub fn queue_command(queue: &EditCommandQueue, command: EditCommand) { + queue + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push_back(command); +} + +fn pop_command(queue: &EditCommandQueue) -> Option { + queue + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .pop_front() +} + +/// Transparent bridge between native menu actions and Iced's focused widget. +/// +/// Muda's predefined macOS edit items send Cocoa responder selectors. Iced's +/// canvas is not a native text responder, so those selectors swallow keyboard +/// shortcuts without reaching the focused Iced text widget. Custom menu items +/// enqueue commands here; the bridge replays the equivalent keyboard sequence +/// through the widget tree on the redraw requested after the menu event. +pub struct NativeEdit<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer> { + content: Element<'a, Message, Theme, Renderer>, + commands: EditCommandQueue, +} + +impl<'a, Message, Theme, Renderer> NativeEdit<'a, Message, Theme, Renderer> { + pub fn new( + content: impl Into>, + commands: EditCommandQueue, + ) -> Self { + Self { + content: content.into(), + commands, + } + } +} + +#[derive(Default)] +struct State; + +fn modifier_sync_event(event: &Event) -> Option { + let Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = event else { + return None; + }; + + Some(Event::Keyboard(keyboard::Event::ModifiersChanged( + *modifiers, + ))) +} + +fn command_events(command: EditCommand) -> [Event; 4] { + let (character, modified_character, physical_key, modifiers) = match command { + EditCommand::Undo => ("z", "z", key::Code::KeyZ, Modifiers::COMMAND), + EditCommand::Redo => ( + "z", + "Z", + key::Code::KeyZ, + Modifiers::COMMAND | Modifiers::SHIFT, + ), + EditCommand::Cut => ("x", "x", key::Code::KeyX, Modifiers::COMMAND), + EditCommand::Copy => ("c", "c", key::Code::KeyC, Modifiers::COMMAND), + EditCommand::Paste => ("v", "v", key::Code::KeyV, Modifiers::COMMAND), + EditCommand::SelectAll => ("a", "a", key::Code::KeyA, Modifiers::COMMAND), + }; + let key = Key::Character(character.into()); + + [ + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)), + Event::Keyboard(keyboard::Event::KeyPressed { + key: key.clone(), + modified_key: Key::Character(modified_character.into()), + physical_key: key::Physical::Code(physical_key), + location: Location::Standard, + modifiers, + text: None, + }), + Event::Keyboard(keyboard::Event::KeyReleased { + key, + location: Location::Standard, + modifiers, + }), + Event::Keyboard(keyboard::Event::ModifiersChanged(Modifiers::default())), + ] +} + +impl<'a, Message, Theme, Renderer> Widget + for NativeEdit<'a, Message, Theme, Renderer> +where + Renderer: renderer::Renderer, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State) + } + + fn children(&self) -> Vec { + vec![Tree::new(&self.content)] + } + + fn diff(&self, tree: &mut Tree) { + tree.diff_children(std::slice::from_ref(&self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &self, + tree: &mut Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + self.content + .as_widget() + .layout(&mut tree.children[0], renderer, limits) + } + + fn operate( + &self, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn Operation, + ) { + self.content + .as_widget() + .operate(&mut tree.children[0], layout, renderer, operation); + } + + 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 mut status = event::Status::Ignored; + + if matches!( + event, + Event::Window(iced::window::Event::RedrawRequested(_)) + ) { + while let Some(command) = pop_command(&self.commands) { + for command_event in command_events(command) { + if self.content.as_widget_mut().on_event( + &mut tree.children[0], + command_event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) == event::Status::Captured + { + status = event::Status::Captured; + } + } + } + } + + if let Some(sync_event) = modifier_sync_event(&event) { + let _ = self.content.as_widget_mut().on_event( + &mut tree.children[0], + sync_event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + } + + if self.content.as_widget_mut().on_event( + &mut tree.children[0], + event, + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ) == event::Status::Captured + { + event::Status::Captured + } else { + status + } + } + + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + cursor, + viewport, + ); + } + + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'_>, + renderer: &Renderer, + translation: Vector, + ) -> Option> { + self.content + .as_widget_mut() + .overlay(&mut tree.children[0], layout, renderer, translation) + } +} + +impl<'a, Message, Theme, Renderer> From> + for Element<'a, Message, Theme, Renderer> +where + Message: 'a, + Theme: 'a, + Renderer: 'a + renderer::Renderer, +{ + fn from(bridge: NativeEdit<'a, Message, Theme, Renderer>) -> Self { + Element::new(bridge) + } +} + +pub fn native_edit<'a, Message, Theme, Renderer>( + content: impl Into>, + commands: EditCommandQueue, +) -> NativeEdit<'a, Message, Theme, Renderer> { + NativeEdit::new(content, commands) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_press_produces_modifier_sync_event() { + let modifiers = Modifiers::COMMAND | Modifiers::SHIFT; + let event = Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Character("v".into()), + modified_key: Key::Character("V".into()), + physical_key: key::Physical::Code(key::Code::KeyV), + location: Location::Standard, + modifiers, + text: Some("v".into()), + }); + + assert_eq!( + modifier_sync_event(&event), + Some(Event::Keyboard(keyboard::Event::ModifiersChanged( + modifiers + ))) + ); + } + + #[test] + fn paste_command_replays_complete_shortcut_sequence() { + let events = command_events(EditCommand::Paste); + + assert!(matches!( + events[0], + Event::Keyboard(keyboard::Event::ModifiersChanged(Modifiers::COMMAND)) + )); + assert!(matches!( + &events[1], + Event::Keyboard(keyboard::Event::KeyPressed { + key: Key::Character(key), + modifiers: Modifiers::COMMAND, + .. + }) if key == "v" + )); + assert!(matches!( + &events[2], + Event::Keyboard(keyboard::Event::KeyReleased { + key: Key::Character(key), + .. + }) if key == "v" + )); + assert!(matches!( + events[3], + Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) if modifiers.is_empty() + )); + } + + #[test] + fn command_queue_preserves_edit_order() { + let queue = command_queue(); + queue_command(&queue, EditCommand::Copy); + queue_command(&queue, EditCommand::Paste); + + assert_eq!(pop_command(&queue), Some(EditCommand::Copy)); + assert_eq!(pop_command(&queue), Some(EditCommand::Paste)); + assert_eq!(pop_command(&queue), None); + } +} diff --git a/crates/bds-ui/src/platform/menu.rs b/crates/bds-ui/src/platform/menu.rs index a6cc68b..c42a684 100644 --- a/crates/bds-ui/src/platform/menu.rs +++ b/crates/bds-ui/src/platform/menu.rs @@ -10,8 +10,9 @@ use bds_core::i18n::{UiLocale, translate}; /// Every custom menu item that the application handles. /// -/// Predefined OS items (Undo, Redo, Cut, Copy, Paste, SelectAll, etc.) -/// are handled by the platform and do not appear here. +/// Edit commands are custom actions because Iced widgets are not native Cocoa +/// text responders; routing them through the app preserves native menu items +/// and shortcuts while operating on the focused Iced widget. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum MenuAction { // File @@ -22,7 +23,13 @@ pub enum MenuAction { OpenDataFolder, ConnectServer, DisconnectServer, - // Edit (custom items only) + // Edit + Undo, + Redo, + Cut, + Copy, + Paste, + SelectAll, Find, Replace, EditPreferences, @@ -64,6 +71,12 @@ impl MenuAction { MenuAction::OpenDataFolder, MenuAction::ConnectServer, MenuAction::DisconnectServer, + MenuAction::Undo, + MenuAction::Redo, + MenuAction::Cut, + MenuAction::Copy, + MenuAction::Paste, + MenuAction::SelectAll, MenuAction::Find, MenuAction::Replace, MenuAction::EditPreferences, @@ -101,6 +114,12 @@ impl MenuAction { "open_data_folder" => Self::OpenDataFolder, "connect_server" => Self::ConnectServer, "disconnect_server" => Self::DisconnectServer, + "undo" => Self::Undo, + "redo" => Self::Redo, + "cut" => Self::Cut, + "copy" => Self::Copy, + "paste" => Self::Paste, + "select_all" => Self::SelectAll, "find" => Self::Find, "replace" => Self::Replace, "edit_preferences" => Self::EditPreferences, @@ -141,6 +160,12 @@ impl MenuAction { Self::OpenDataFolder => "menu.item.openDataFolder", Self::ConnectServer => "menu.item.connectServer", Self::DisconnectServer => "menu.item.disconnectServer", + Self::Undo => "menu.item.undo", + Self::Redo => "menu.item.redo", + Self::Cut => "menu.item.cut", + Self::Copy => "menu.item.copy", + Self::Paste => "menu.item.paste", + Self::SelectAll => "menu.item.selectAll", Self::Find => "menu.item.find", Self::Replace => "menu.item.replace", Self::EditPreferences => "menu.item.editPreferences", @@ -357,13 +382,46 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) { // -- Edit -- let edit_menu = Submenu::new(translate(locale, "menu.group.edit"), true); - let _ = edit_menu.append(&PredefinedMenuItem::undo(None)); - let _ = edit_menu.append(&PredefinedMenuItem::redo(None)); + let _ = edit_menu.append(&item( + &mut reg, + MenuAction::Undo, + locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyZ)), + )); + let _ = edit_menu.append(&item( + &mut reg, + MenuAction::Redo, + locale, + Some(Accelerator::new( + Some(CMD_OR_CTRL | Modifiers::SHIFT), + Code::KeyZ, + )), + )); let _ = edit_menu.append(&PredefinedMenuItem::separator()); - let _ = edit_menu.append(&PredefinedMenuItem::cut(None)); - let _ = edit_menu.append(&PredefinedMenuItem::copy(None)); - let _ = edit_menu.append(&PredefinedMenuItem::paste(None)); - let _ = edit_menu.append(&PredefinedMenuItem::select_all(None)); + let _ = edit_menu.append(&item( + &mut reg, + MenuAction::Cut, + locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyX)), + )); + let _ = edit_menu.append(&item( + &mut reg, + MenuAction::Copy, + locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyC)), + )); + let _ = edit_menu.append(&item( + &mut reg, + MenuAction::Paste, + locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyV)), + )); + let _ = edit_menu.append(&item( + &mut reg, + MenuAction::SelectAll, + locale, + Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyA)), + )); let _ = edit_menu.append(&PredefinedMenuItem::separator()); let _ = edit_menu.append(&item( &mut reg, diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 0ab4d37..ce4e4ea 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -8,6 +8,12 @@ menu-item-importMedia = Medien importieren... menu-item-save = Speichern menu-item-openInBrowser = Im Browser öffnen menu-item-openDataFolder = Datenordner öffnen +menu-item-undo = Rückgängig +menu-item-redo = Wiederholen +menu-item-cut = Ausschneiden +menu-item-copy = Kopieren +menu-item-paste = Einfügen +menu-item-selectAll = Alles auswählen menu-item-find = Suchen menu-item-replace = Ersetzen menu-item-editPreferences = Einstellungen bearbeiten diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index d6fb658..7e35b59 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -8,6 +8,12 @@ menu-item-importMedia = Import Media... menu-item-save = Save menu-item-openInBrowser = Open in Browser menu-item-openDataFolder = Open Data Folder +menu-item-undo = Undo +menu-item-redo = Redo +menu-item-cut = Cut +menu-item-copy = Copy +menu-item-paste = Paste +menu-item-selectAll = Select All menu-item-find = Find menu-item-replace = Replace menu-item-editPreferences = Edit Preferences diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 2db9e3a..e6f341f 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -8,6 +8,12 @@ menu-item-importMedia = Importar medios... menu-item-save = Guardar menu-item-openInBrowser = Abrir en el navegador menu-item-openDataFolder = Abrir carpeta de datos +menu-item-undo = Deshacer +menu-item-redo = Rehacer +menu-item-cut = Cortar +menu-item-copy = Copiar +menu-item-paste = Pegar +menu-item-selectAll = Seleccionar todo menu-item-find = Buscar menu-item-replace = Reemplazar menu-item-editPreferences = Editar preferencias diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 9ef5eca..2bd3f1b 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -8,6 +8,12 @@ menu-item-importMedia = Importer des médias... menu-item-save = Enregistrer menu-item-openInBrowser = Ouvrir dans le navigateur menu-item-openDataFolder = Ouvrir le dossier de données +menu-item-undo = Annuler +menu-item-redo = Rétablir +menu-item-cut = Couper +menu-item-copy = Copier +menu-item-paste = Coller +menu-item-selectAll = Tout sélectionner menu-item-find = Rechercher menu-item-replace = Remplacer menu-item-editPreferences = Modifier les préférences diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index b346947..45d8d5d 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -8,6 +8,12 @@ menu-item-importMedia = Importa media... menu-item-save = Salva menu-item-openInBrowser = Apri nel browser menu-item-openDataFolder = Apri cartella dati +menu-item-undo = Annulla +menu-item-redo = Ripeti +menu-item-cut = Taglia +menu-item-copy = Copia +menu-item-paste = Incolla +menu-item-selectAll = Seleziona tutto menu-item-find = Trova menu-item-replace = Sostituisci menu-item-editPreferences = Modifica preferenze