From a4213e9672eb175a51908c9aef40568f9bc748ca Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 19 Mar 2026 17:58:46 +0800 Subject: [PATCH 1/5] fix: route macOS Monaco edit commands explicitly --- src/apps/desktop/src/api/app_state.rs | 4 + src/apps/desktop/src/api/commands.rs | 4 + src/apps/desktop/src/api/i18n_api.rs | 2 + src/apps/desktop/src/api/system_api.rs | 54 +++++ src/apps/desktop/src/lib.rs | 15 +- src/apps/desktop/src/macos_menubar.rs | 156 ++++++++----- .../components/PersistentFooterActions.tsx | 220 +++++++++--------- .../api/service-api/SystemAPI.ts | 12 +- .../tools/editor/components/CodeEditor.tsx | 158 +++++++++---- .../tools/editor/core/MonacoEditorCore.tsx | 8 + .../services/ActiveMonacoEditorService.ts | 197 ++++++++++++++++ 11 files changed, 614 insertions(+), 216 deletions(-) create mode 100644 src/web-ui/src/tools/editor/services/ActiveMonacoEditorService.ts diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 4bf6d38c..cf6b3574 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -69,6 +69,7 @@ pub struct AppState { pub miniapp_manager: Arc, pub js_worker_pool: Option>, pub statistics: Arc>, + pub macos_edit_menu_mode: Arc>, pub start_time: std::time::Instant, // SSH Remote connection state pub ssh_manager: Arc>>, @@ -257,6 +258,9 @@ impl AppState { miniapp_manager, js_worker_pool, statistics, + macos_edit_menu_mode: Arc::new(RwLock::new( + crate::macos_menubar::EditMenuMode::System, + )), start_time, // SSH Remote connection state ssh_manager, diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index af94a82a..d2accfd3 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -204,10 +204,12 @@ async fn clear_active_workspace_context(state: &State<'_, AppState>, app: &AppHa .get_config::(Some("app.language")) .await .unwrap_or_else(|_| "zh-CN".to_string()); + let edit_mode = *state.macos_edit_menu_mode.read().await; let _ = crate::macos_menubar::set_macos_menubar_with_mode( app, &language, crate::macos_menubar::MenubarMode::Startup, + edit_mode, ); } } @@ -261,10 +263,12 @@ async fn apply_active_workspace_context( .get_config::(Some("app.language")) .await .unwrap_or_else(|_| "zh-CN".to_string()); + let edit_mode = *state.macos_edit_menu_mode.read().await; let _ = crate::macos_menubar::set_macos_menubar_with_mode( app, &language, crate::macos_menubar::MenubarMode::Workspace, + edit_mode, ); } } diff --git a/src/apps/desktop/src/api/i18n_api.rs b/src/apps/desktop/src/api/i18n_api.rs index 98398842..13f08a93 100644 --- a/src/apps/desktop/src/api/i18n_api.rs +++ b/src/apps/desktop/src/api/i18n_api.rs @@ -68,10 +68,12 @@ pub async fn i18n_set_language( } else { crate::macos_menubar::MenubarMode::Startup }; + let edit_mode = *state.macos_edit_menu_mode.read().await; let _ = crate::macos_menubar::set_macos_menubar_with_mode( &_app, &request.language, mode, + edit_mode, ); } Ok(format!("Language switched to: {}", request.language)) diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index a6ae09b3..7e4a694c 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -1,7 +1,9 @@ //! System API +use crate::api::app_state::AppState; use bitfun_core::service::system; use serde::{Deserialize, Serialize}; +use tauri::State; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -55,6 +57,12 @@ pub struct CommandOutputResponse { pub success: bool, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetMacosEditMenuModeRequest { + pub mode: crate::macos_menubar::EditMenuMode, +} + #[tauri::command] pub async fn check_command_exists(command: String) -> Result { let result = system::check_command(&command); @@ -112,3 +120,49 @@ pub async fn run_system_command( success: result.success, }) } + +#[tauri::command] +pub async fn set_macos_edit_menu_mode( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: SetMacosEditMenuModeRequest, +) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let current_mode = *state.macos_edit_menu_mode.read().await; + if current_mode == request.mode { + return Ok(()); + } + + { + let mut edit_mode = state.macos_edit_menu_mode.write().await; + *edit_mode = request.mode; + } + + let language = state + .config_service + .get_config::(Some("app.language")) + .await + .unwrap_or_else(|_| "zh-CN".to_string()); + let menubar_mode = if state.workspace_path.read().await.is_some() { + crate::macos_menubar::MenubarMode::Workspace + } else { + crate::macos_menubar::MenubarMode::Startup + }; + + crate::macos_menubar::set_macos_menubar_with_mode( + &app, + &language, + menubar_mode, + request.mode, + ) + .map_err(|error| error.to_string())?; + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (&state, &app, &request); + } + + Ok(()) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index e1a6303b..d34e6a60 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -145,15 +145,8 @@ pub async fn run() { #[cfg(target_os = "macos")] { app.on_menu_event(|app, event| { - let event_name = if event.id() == "bitfun.open_project" { - Some("bitfun_menu_open_project") - } else if event.id() == "bitfun.new_project" { - Some("bitfun_menu_new_project") - } else if event.id() == "bitfun.about" { - Some("bitfun_menu_about") - } else { - None - }; + let event_name = + crate::macos_menubar::menu_event_name_for_id(event.id().as_ref()); if let Some(event_name) = event_name { let _ = app.emit(event_name, ()); @@ -214,6 +207,7 @@ pub async fn run() { let app_state: tauri::State<'_, api::app_state::AppState> = app.state(); let config_service = app_state.config_service.clone(); let workspace_path = app_state.workspace_path.clone(); + let macos_edit_menu_mode = app_state.macos_edit_menu_mode.clone(); tokio::spawn(async move { let language = config_service @@ -227,11 +221,13 @@ pub async fn run() { } else { crate::macos_menubar::MenubarMode::Startup }; + let edit_mode = *macos_edit_menu_mode.read().await; let _ = crate::macos_menubar::set_macos_menubar_with_mode( &app_handle_for_menu, &language, mode, + edit_mode, ); }); } @@ -581,6 +577,7 @@ pub async fn run() { check_command_exists, check_commands_exist, run_system_command, + set_macos_edit_menu_mode, i18n_get_current_language, i18n_set_language, i18n_get_supported_languages, diff --git a/src/apps/desktop/src/macos_menubar.rs b/src/apps/desktop/src/macos_menubar.rs index d348a967..3382dc78 100644 --- a/src/apps/desktop/src/macos_menubar.rs +++ b/src/apps/desktop/src/macos_menubar.rs @@ -1,7 +1,7 @@ //! macOS Native Menubar #[cfg(target_os = "macos")] -use tauri::menu::{MenuBuilder, SubmenuBuilder}; +use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MenubarMode { @@ -9,6 +9,35 @@ pub enum MenubarMode { Workspace, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditMenuMode { + System, + Monaco, +} + +pub const MENU_ID_EDIT_UNDO: &str = "bitfun.edit.undo"; +pub const MENU_ID_EDIT_REDO: &str = "bitfun.edit.redo"; +pub const MENU_ID_EDIT_CUT: &str = "bitfun.edit.cut"; +pub const MENU_ID_EDIT_COPY: &str = "bitfun.edit.copy"; +pub const MENU_ID_EDIT_PASTE: &str = "bitfun.edit.paste"; +pub const MENU_ID_EDIT_SELECT_ALL: &str = "bitfun.edit.select_all"; + +pub fn menu_event_name_for_id(id: &str) -> Option<&'static str> { + match id { + "bitfun.open_project" => Some("bitfun_menu_open_project"), + "bitfun.new_project" => Some("bitfun_menu_new_project"), + "bitfun.about" => Some("bitfun_menu_about"), + MENU_ID_EDIT_UNDO => Some("bitfun_menu_edit_undo"), + MENU_ID_EDIT_REDO => Some("bitfun_menu_edit_redo"), + MENU_ID_EDIT_CUT => Some("bitfun_menu_edit_cut"), + MENU_ID_EDIT_COPY => Some("bitfun_menu_edit_copy"), + MENU_ID_EDIT_PASTE => Some("bitfun_menu_edit_paste"), + MENU_ID_EDIT_SELECT_ALL => Some("bitfun_menu_edit_select_all"), + _ => None, + } +} + #[cfg(target_os = "macos")] #[derive(Clone)] struct MenubarLabels { @@ -17,6 +46,12 @@ struct MenubarLabels { open_project: &'static str, new_project: &'static str, about_bitfun: &'static str, + undo: &'static str, + redo: &'static str, + cut: &'static str, + copy: &'static str, + paste: &'static str, + select_all: &'static str, } #[cfg(target_os = "macos")] @@ -28,6 +63,12 @@ fn labels_for_language(language: &str) -> MenubarLabels { open_project: "Open Project…", new_project: "New Project…", about_bitfun: "About BitFun", + undo: "Undo", + redo: "Redo", + cut: "Cut", + copy: "Copy", + paste: "Paste", + select_all: "Select All", }, _ => MenubarLabels { project_menu: "工程", @@ -35,6 +76,12 @@ fn labels_for_language(language: &str) -> MenubarLabels { open_project: "打开工程…", new_project: "新建工程…", about_bitfun: "关于 BitFun", + undo: "撤销", + redo: "重做", + cut: "剪切", + copy: "复制", + paste: "粘贴", + select_all: "全选", }, } } @@ -44,68 +91,70 @@ pub fn set_macos_menubar_with_mode( app: &tauri::AppHandle, language: &str, mode: MenubarMode, + edit_mode: EditMenuMode, ) -> tauri::Result<()> { let labels = labels_for_language(language); + let _ = mode; - let menu = match mode { - MenubarMode::Startup => { - let app_menu = SubmenuBuilder::new(app, "BitFun") - .text("bitfun.about", labels.about_bitfun) - .separator() - .quit() - .build()?; - - let edit_menu = SubmenuBuilder::new(app, labels.edit_menu) - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; + let app_menu = SubmenuBuilder::new(app, "BitFun") + .text("bitfun.about", labels.about_bitfun) + .separator() + .quit() + .build()?; - let project_menu = SubmenuBuilder::new(app, labels.project_menu) - .text("bitfun.open_project", labels.open_project) - .text("bitfun.new_project", labels.new_project) - .build()?; + let edit_menu = match edit_mode { + EditMenuMode::System => SubmenuBuilder::new(app, labels.edit_menu) + .undo() + .redo() + .separator() + .cut() + .copy() + .paste() + .select_all() + .build()?, + EditMenuMode::Monaco => { + let undo = MenuItemBuilder::with_id(MENU_ID_EDIT_UNDO, labels.undo) + .accelerator("Cmd+Z") + .build(app)?; + let redo = MenuItemBuilder::with_id(MENU_ID_EDIT_REDO, labels.redo) + .accelerator("Cmd+Shift+Z") + .build(app)?; + let cut = MenuItemBuilder::with_id(MENU_ID_EDIT_CUT, labels.cut) + .accelerator("Cmd+X") + .build(app)?; + let copy = MenuItemBuilder::with_id(MENU_ID_EDIT_COPY, labels.copy) + .accelerator("Cmd+C") + .build(app)?; + let paste = MenuItemBuilder::with_id(MENU_ID_EDIT_PASTE, labels.paste) + .accelerator("Cmd+V") + .build(app)?; + let select_all = MenuItemBuilder::with_id(MENU_ID_EDIT_SELECT_ALL, labels.select_all) + .accelerator("Cmd+A") + .build(app)?; - MenuBuilder::new(app) - .item(&app_menu) - .item(&edit_menu) - .item(&project_menu) - .build()? - } - MenubarMode::Workspace => { - let app_menu = SubmenuBuilder::new(app, "BitFun") - .text("bitfun.about", labels.about_bitfun) - .separator() - .quit() - .build()?; - - let edit_menu = SubmenuBuilder::new(app, labels.edit_menu) - .undo() - .redo() + SubmenuBuilder::new(app, labels.edit_menu) + .item(&undo) + .item(&redo) .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; - - let project_menu = SubmenuBuilder::new(app, labels.project_menu) - .text("bitfun.open_project", labels.open_project) - .text("bitfun.new_project", labels.new_project) - .build()?; - - MenuBuilder::new(app) - .item(&app_menu) - .item(&edit_menu) - .item(&project_menu) + .item(&cut) + .item(©) + .item(&paste) + .item(&select_all) .build()? } }; + let project_menu = SubmenuBuilder::new(app, labels.project_menu) + .text("bitfun.open_project", labels.open_project) + .text("bitfun.new_project", labels.new_project) + .build()?; + + let menu = MenuBuilder::new(app) + .item(&app_menu) + .item(&edit_menu) + .item(&project_menu) + .build()?; + app.set_menu(menu)?; Ok(()) } @@ -115,6 +164,7 @@ pub fn set_macos_menubar_with_mode( _app: &tauri::AppHandle, _language: &str, _mode: MenubarMode, + _edit_mode: EditMenuMode, ) -> tauri::Result<()> { Ok(()) } diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 3e7f7b70..f0501e23 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Smartphone, Globe } from 'lucide-react'; +import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Wifi, Globe } from 'lucide-react'; import { Tooltip, Modal } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneManager } from '../../../hooks/useSceneManager'; @@ -125,126 +125,126 @@ const PersistentFooterActions: React.FC = () => { return ( <> -
-
-
- - - - - {menuOpen && ( - <> -
-
+
+
+ + + + + {menuOpen && ( + <> +
+
+ + + +
- -
- -
- - -
- - )} -
+
+ + +
+ + )} +
- - - - - - - -
+ + + -
- + + + +
+ +
+ +
-
- setShowAbout(false)} /> - setShowRemoteConnect(false)} /> - setShowRemoteDisclaimer(false)} - title={t('remoteConnect.disclaimerTitle')} - showCloseButton - size="large" - contentInset - > - setShowAbout(false)} /> + setShowRemoteConnect(false)} /> + setShowRemoteDisclaimer(false)} - onAgree={handleAgreeDisclaimer} - /> - + title={t('remoteConnect.disclaimerTitle')} + showCloseButton + size="large" + contentInset + > + setShowRemoteDisclaimer(false)} + onAgree={handleAgreeDisclaimer} + /> + ); }; diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index 84f2d5d5..8c31c079 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -113,7 +113,17 @@ export class SystemAPI { throw createTauriCommandError('check_commands_exist', error, { commands }); } } + + async setMacosEditMenuMode(mode: 'system' | 'monaco'): Promise { + try { + await api.invoke('set_macos_edit_menu_mode', { + request: { mode } + }); + } catch (error) { + throw createTauriCommandError('set_macos_edit_menu_mode', error, { mode }); + } + } } -export const systemAPI = new SystemAPI(); \ No newline at end of file +export const systemAPI = new SystemAPI(); diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 04dd37a7..f1e43540 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -10,6 +10,7 @@ import { AlertCircle } from 'lucide-react'; import * as monaco from 'monaco-editor'; import { monacoInitManager } from '../services/MonacoInitManager'; import { monacoModelManager } from '../services/MonacoModelManager'; +import { activeMonacoEditorService } from '../services/ActiveMonacoEditorService'; import { forceRegisterTheme, BitFunDarkTheme, @@ -92,6 +93,15 @@ function hasVeryLongLine(content: string, maxLineLength: number): boolean { return false; } +function isMacOSDesktop(): boolean { + if (typeof window === 'undefined') { + return false; + } + + const isTauri = '__TAURI__' in window; + return isTauri && typeof navigator.platform === 'string' && navigator.platform.toUpperCase().includes('MAC'); +} + const CodeEditor: React.FC = ({ filePath: rawFilePath, workspacePath, @@ -202,6 +212,8 @@ const CodeEditor: React.FC = ({ const userIndentRef = useRef<{ tab_size: number; insert_spaces: boolean } | null>(null); const largeFileModeRef = useRef(false); const largeFileExpansionBlockedLogRef = useRef(false); + const pendingModelContentRef = useRef(null); + const macosEditorBindingCleanupRef = useRef<(() => void) | null>(null); const detectLargeFileMode = useCallback((nextContent: string, fileSizeBytes?: number): boolean => { const size = typeof fileSizeBytes === 'number' && fileSizeBytes >= 0 @@ -226,6 +238,29 @@ const CodeEditor: React.FC = ({ } }, [detectLargeFileMode, filePath]); + const applyExternalContentToModel = useCallback((nextContent: string) => { + const model = modelRef.current; + if (!model) { + pendingModelContentRef.current = nextContent; + return; + } + + pendingModelContentRef.current = null; + if (model.getValue() === nextContent) { + return; + } + + const previousLoadingState = isLoadingContentRef.current; + isLoadingContentRef.current = true; + model.setValue(nextContent); + + queueMicrotask(() => { + if (!isUnmountedRef.current) { + isLoadingContentRef.current = previousLoadingState; + } + }); + }, []); + const shouldBlockLargeFileExpansionClick = useCallback((target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) { return false; @@ -250,6 +285,7 @@ const CodeEditor: React.FC = ({ useEffect(() => { filePathRef.current = filePath; + pendingModelContentRef.current = null; }, [filePath]); useEffect(() => { @@ -580,6 +616,7 @@ const CodeEditor: React.FC = ({ editor = monaco.editor.create(containerRef.current, editorOptions); editorRef.current = editor; setEditorInstance(editor); + macosEditorBindingCleanupRef.current = activeMonacoEditorService.bindEditor(editor); // #endregion (containerRef.current as any).__monacoEditor = editor; @@ -794,6 +831,10 @@ const CodeEditor: React.FC = ({ return () => { isUnmountedRef.current = true; + if (macosEditorBindingCleanupRef.current) { + macosEditorBindingCleanupRef.current(); + macosEditorBindingCleanupRef.current = null; + } if (delayedFontApplyTimerRef.current) { clearTimeout(delayedFontApplyTimerRef.current); delayedFontApplyTimerRef.current = null; @@ -836,26 +877,16 @@ const CodeEditor: React.FC = ({ }, [filePath, detectedLanguage, detectLargeFileMode]); useEffect(() => { - if (modelRef.current && monacoReady && !loading) { - const currentValue = modelRef.current.getValue(); - if (content !== currentValue) { - isLoadingContentRef.current = true; - - monacoModelManager.updateModelContent(filePath, content, !hasChanges); - - queueMicrotask(() => { - isLoadingContentRef.current = false; - if (modelRef.current && !isUnmountedRef.current && !hasChanges) { - savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); - } - }); - - if (content && !lspReady) { - setLspReady(true); - } - } + if (monacoReady && pendingModelContentRef.current !== null) { + applyExternalContentToModel(pendingModelContentRef.current); } - }, [content, monacoReady, loading, lspReady, hasChanges, filePath]); + }, [monacoReady, applyExternalContentToModel]); + + useEffect(() => { + if (content && !lspReady) { + setLspReady(true); + } + }, [content, lspReady]); useEffect(() => { if (modelRef.current && monacoReady) { @@ -1029,13 +1060,19 @@ const CodeEditor: React.FC = ({ updateLargeFileMode(content); setContent(content); originalContentRef.current = content; - if (modelRef.current) { - modelRef.current.setValue(content); - } + setHasChanges(false); + hasChangesRef.current = false; + applyExternalContentToModel(content); + queueMicrotask(() => { + if (modelRef.current && !isUnmountedRef.current) { + savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); + monacoModelManager.markAsSaved(filePath); + } + }); } catch (err) { log.warn('Failed to reload file with new encoding', err); } - }, [filePath, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, updateLargeFileMode]); const handleLanguageConfirm = useCallback((languageId: string) => { userLanguageOverrideRef.current = true; @@ -1087,6 +1124,7 @@ const CodeEditor: React.FC = ({ originalContentRef.current = fileContent; setHasChanges(false); hasChangesRef.current = false; + applyExternalContentToModel(fileContent); // NOTE: Do NOT call onContentChange here during initial load. // Calling it triggers parent re-render which unmounts this component, @@ -1120,7 +1158,7 @@ const CodeEditor: React.FC = ({ isLoadingContentRef.current = false; }); } - }, [filePath, detectedLanguage, t, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, detectedLanguage, t, updateLargeFileMode]); // Save file content const saveFileContent = useCallback(async () => { @@ -1185,19 +1223,43 @@ const CodeEditor: React.FC = ({ // Container-level keyboard event handler, solves global conflict issues with multiple editor instances const handleContainerKeyDown = useCallback((event: React.KeyboardEvent) => { - if ((event.ctrlKey || event.metaKey) && event.key === 's') { - // Check if editor has focus - const hasFocus = editorRef.current?.hasTextFocus() ?? false; - - if (hasFocus) { - event.preventDefault(); - event.stopPropagation(); - - saveFileContentRef.current?.(); + const hasFocus = editorRef.current?.hasTextFocus() ?? false; + if (!hasFocus) { + return; + } + + const isModKey = event.ctrlKey || event.metaKey; + const lowerKey = event.key.toLowerCase(); + + if (isModKey && lowerKey === 's') { + event.preventDefault(); + event.stopPropagation(); + saveFileContentRef.current?.(); + return; + } + + if (isModKey && lowerKey === 'z') { + if (isMacOSDesktop()) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + if (event.shiftKey) { + editorRef.current?.trigger('keyboard', 'redo', null); + } else { + editorRef.current?.trigger('keyboard', 'undo', null); } - // If no focus, don't prevent event propagation, let other editors or parent components handle it + return; } - }, [filePath]); + + if (!event.metaKey && event.ctrlKey && lowerKey === 'y') { + event.preventDefault(); + event.stopPropagation(); + editorRef.current?.trigger('keyboard', 'redo', null); + } + }, []); // Check file modifications const checkFileModification = useCallback(async () => { @@ -1237,6 +1299,7 @@ const CodeEditor: React.FC = ({ setHasChanges(false); hasChangesRef.current = false; lastModifiedTimeRef.current = currentModifiedTime; + applyExternalContentToModel(fileContent); onContentChange?.(fileContent, false); @@ -1256,7 +1319,7 @@ const CodeEditor: React.FC = ({ } finally { isCheckingFileRef.current = false; } - }, [filePath, hasChanges, monacoReady, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, hasChanges, monacoReady, onContentChange, t, updateLargeFileMode]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); @@ -1466,13 +1529,22 @@ const CodeEditor: React.FC = ({ const currentPosition = editor?.getPosition(); - if (editor) { - editor.setValue(content); - - if (currentPosition) { - editor.setPosition(currentPosition); - } + setContent(content); + originalContentRef.current = content; + setHasChanges(false); + hasChangesRef.current = false; + applyExternalContentToModel(content); + + if (editor && currentPosition) { + editor.setPosition(currentPosition); } + + queueMicrotask(() => { + if (modelRef.current && !isUnmountedRef.current) { + savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); + monacoModelManager.markAsSaved(filePath); + } + }); } catch (error) { log.error('Failed to reload file', error); } @@ -1494,7 +1566,7 @@ const CodeEditor: React.FC = ({ return () => { unsubscribers.forEach(unsub => unsub()); }; - }, [monacoReady, filePath, updateLargeFileMode]); + }, [applyExternalContentToModel, monacoReady, filePath, updateLargeFileMode]); useEffect(() => { userLanguageOverrideRef.current = false; diff --git a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx index 8d7e7938..ae249469 100644 --- a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx +++ b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx @@ -15,6 +15,7 @@ import { monacoModelManager } from '../services/MonacoModelManager'; import { themeManager } from '../services/ThemeManager'; import { editorExtensionManager } from '../services/EditorExtensionManager'; import { buildEditorOptions } from '../services/EditorOptionsBuilder'; +import { activeMonacoEditorService } from '../services/ActiveMonacoEditorService'; import type { MonacoEditorCoreProps } from './types'; import type { EditorExtensionContext } from '../services/EditorExtensionManager'; import type { EditorOptionsOverrides } from '../services/EditorOptionsBuilder'; @@ -66,6 +67,7 @@ export const MonacoEditorCore = forwardRef(''); const isUnmountedRef = useRef(false); const disposablesRef = useRef([]); + const macosEditorBindingCleanupRef = useRef<(() => void) | null>(null); const hasJumpedRef = useRef(false); const [isReady, setIsReady] = useState(false); @@ -146,6 +148,7 @@ export const MonacoEditorCore = forwardRef d.dispose()); disposablesRef.current = []; + + if (macosEditorBindingCleanupRef.current) { + macosEditorBindingCleanupRef.current(); + macosEditorBindingCleanupRef.current = null; + } if (editorRef.current) { editorRef.current.dispose(); diff --git a/src/web-ui/src/tools/editor/services/ActiveMonacoEditorService.ts b/src/web-ui/src/tools/editor/services/ActiveMonacoEditorService.ts new file mode 100644 index 00000000..b4f05c4f --- /dev/null +++ b/src/web-ui/src/tools/editor/services/ActiveMonacoEditorService.ts @@ -0,0 +1,197 @@ +import * as monaco from 'monaco-editor'; +import { createLogger } from '@/shared/utils/logger'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; + +const log = createLogger('ActiveMonacoEditorService'); + +type MacosEditMenuMode = 'system' | 'monaco'; +type EditMenuAction = 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll'; + +const MENU_EVENT_ACTIONS: Array<{ eventName: string; action: EditMenuAction }> = [ + { eventName: 'bitfun_menu_edit_undo', action: 'undo' }, + { eventName: 'bitfun_menu_edit_redo', action: 'redo' }, + { eventName: 'bitfun_menu_edit_cut', action: 'cut' }, + { eventName: 'bitfun_menu_edit_copy', action: 'copy' }, + { eventName: 'bitfun_menu_edit_paste', action: 'paste' }, + { eventName: 'bitfun_menu_edit_select_all', action: 'selectAll' }, +]; + +function isMacOSDesktop(): boolean { + if (typeof window === 'undefined') { + return false; + } + + const isTauri = '__TAURI__' in window; + return isTauri && typeof navigator.platform === 'string' && navigator.platform.toUpperCase().includes('MAC'); +} + +export class ActiveMonacoEditorService { + private activeEditor: monaco.editor.IStandaloneCodeEditor | null = null; + private registeredEditors = new Set(); + private menuBridgePromise: Promise | null = null; + private lastRequestedMenuMode: MacosEditMenuMode | null = null; + + bindEditor(editor: monaco.editor.IStandaloneCodeEditor): () => void { + if (!isMacOSDesktop()) { + return () => {}; + } + + this.registeredEditors.add(editor); + void this.ensureMacOSMenuBridge(); + + if (editor.hasTextFocus()) { + this.setActiveEditor(editor); + } + + const focusDisposable = editor.onDidFocusEditorText(() => { + this.setActiveEditor(editor); + }); + + const blurDisposable = editor.onDidBlurEditorText(() => { + window.setTimeout(() => { + if (this.activeEditor !== editor) { + return; + } + + if (editor.hasTextFocus()) { + return; + } + + this.activeEditor = null; + void this.setMenuMode('system'); + }, 0); + }); + + return () => { + focusDisposable.dispose(); + blurDisposable.dispose(); + this.unregisterEditor(editor); + }; + } + + executeAction(action: EditMenuAction): boolean { + const editor = this.getActiveEditor(); + if (!editor) { + void this.setMenuMode('system'); + return false; + } + + editor.focus(); + + switch (action) { + case 'undo': + editor.trigger('macos-menu', 'undo', null); + return true; + case 'redo': + editor.trigger('macos-menu', 'redo', null); + return true; + case 'cut': + editor.trigger('macos-menu', 'editor.action.clipboardCutAction', null); + return true; + case 'copy': + editor.trigger('macos-menu', 'editor.action.clipboardCopyAction', null); + return true; + case 'paste': + editor.trigger('macos-menu', 'editor.action.clipboardPasteAction', null); + return true; + case 'selectAll': + editor.trigger('macos-menu', 'editor.action.selectAll', null); + return true; + default: + return false; + } + } + + private getActiveEditor(): monaco.editor.IStandaloneCodeEditor | null { + if (!this.activeEditor) { + return null; + } + + if (!this.registeredEditors.has(this.activeEditor)) { + this.activeEditor = null; + return null; + } + + if (!this.activeEditor.getModel()) { + this.activeEditor = null; + return null; + } + + return this.activeEditor; + } + + private unregisterEditor(editor: monaco.editor.IStandaloneCodeEditor): void { + this.registeredEditors.delete(editor); + + if (this.activeEditor === editor) { + this.activeEditor = null; + void this.setMenuMode('system'); + } + } + + private setActiveEditor(editor: monaco.editor.IStandaloneCodeEditor): void { + if (!this.registeredEditors.has(editor)) { + this.registeredEditors.add(editor); + } + + if (this.activeEditor === editor) { + void this.setMenuMode('monaco'); + return; + } + + this.activeEditor = editor; + void this.setMenuMode('monaco'); + } + + private async ensureMacOSMenuBridge(): Promise { + if (!isMacOSDesktop()) { + return; + } + + if (this.menuBridgePromise) { + return this.menuBridgePromise; + } + + this.menuBridgePromise = (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + + await Promise.all( + MENU_EVENT_ACTIONS.map(async ({ eventName, action }) => + listen(eventName, () => { + this.executeAction(action); + }) + ) + ); + } catch (error) { + log.warn('Failed to initialize macOS Monaco menu bridge', { error }); + this.menuBridgePromise = null; + } + })(); + + return this.menuBridgePromise; + } + + private async setMenuMode(mode: MacosEditMenuMode): Promise { + if (!isMacOSDesktop()) { + return; + } + + if (this.lastRequestedMenuMode === mode) { + return; + } + + this.lastRequestedMenuMode = mode; + + try { + await systemAPI.setMacosEditMenuMode(mode); + } catch (error) { + if (this.lastRequestedMenuMode === mode) { + this.lastRequestedMenuMode = null; + } + log.warn('Failed to switch macOS edit menu mode', { mode, error }); + } + } +} + +export const activeMonacoEditorService = new ActiveMonacoEditorService(); From 07c10016c5e9980eb232260dcde25006c5051df5 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 19 Mar 2026 20:06:08 +0800 Subject: [PATCH 2/5] feat: unify macOS editor menu routing --- src/apps/desktop/src/macos_menubar.rs | 4 +- .../api/service-api/SystemAPI.ts | 2 +- .../tools/editor/components/CodeEditor.tsx | 28 +- .../tools/editor/core/MonacoEditorCore.tsx | 22 +- .../editor/meditor/components/IREditor.tsx | 206 +++++++- .../editor/meditor/components/MEditor.tsx | 91 +++- .../editor/meditor/hooks/useEditorHistory.ts | 45 +- .../services/ActiveEditTargetService.ts | 498 ++++++++++++++++++ .../services/ActiveMonacoEditorService.ts | 197 ------- src/web-ui/src/tools/editor/services/index.ts | 9 + 10 files changed, 865 insertions(+), 237 deletions(-) create mode 100644 src/web-ui/src/tools/editor/services/ActiveEditTargetService.ts delete mode 100644 src/web-ui/src/tools/editor/services/ActiveMonacoEditorService.ts diff --git a/src/apps/desktop/src/macos_menubar.rs b/src/apps/desktop/src/macos_menubar.rs index 3382dc78..59d5a654 100644 --- a/src/apps/desktop/src/macos_menubar.rs +++ b/src/apps/desktop/src/macos_menubar.rs @@ -13,7 +13,7 @@ pub enum MenubarMode { #[serde(rename_all = "camelCase")] pub enum EditMenuMode { System, - Monaco, + Renderer, } pub const MENU_ID_EDIT_UNDO: &str = "bitfun.edit.undo"; @@ -112,7 +112,7 @@ pub fn set_macos_menubar_with_mode( .paste() .select_all() .build()?, - EditMenuMode::Monaco => { + EditMenuMode::Renderer => { let undo = MenuItemBuilder::with_id(MENU_ID_EDIT_UNDO, labels.undo) .accelerator("Cmd+Z") .build(app)?; diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index 8c31c079..0c7c68d7 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -114,7 +114,7 @@ export class SystemAPI { } } - async setMacosEditMenuMode(mode: 'system' | 'monaco'): Promise { + async setMacosEditMenuMode(mode: 'system' | 'renderer'): Promise { try { await api.invoke('set_macos_edit_menu_mode', { request: { mode } diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index f1e43540..50f5e9d6 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -10,7 +10,7 @@ import { AlertCircle } from 'lucide-react'; import * as monaco from 'monaco-editor'; import { monacoInitManager } from '../services/MonacoInitManager'; import { monacoModelManager } from '../services/MonacoModelManager'; -import { activeMonacoEditorService } from '../services/ActiveMonacoEditorService'; +import { activeEditTargetService, createMonacoEditTarget } from '../services/ActiveEditTargetService'; import { forceRegisterTheme, BitFunDarkTheme, @@ -616,7 +616,25 @@ const CodeEditor: React.FC = ({ editor = monaco.editor.create(containerRef.current, editorOptions); editorRef.current = editor; setEditorInstance(editor); - macosEditorBindingCleanupRef.current = activeMonacoEditorService.bindEditor(editor); + const editTarget = createMonacoEditTarget(editor); + const unbindEditTarget = activeEditTargetService.bindTarget(editTarget); + const focusDisposable = editor.onDidFocusEditorText(() => { + activeEditTargetService.setActiveTarget(editTarget.id); + }); + const blurDisposable = editor.onDidBlurEditorText(() => { + window.setTimeout(() => { + if (editor?.hasTextFocus()) { + return; + } + + activeEditTargetService.clearActiveTarget(editTarget.id); + }, 0); + }); + macosEditorBindingCleanupRef.current = () => { + focusDisposable.dispose(); + blurDisposable.dispose(); + unbindEditTarget(); + }; // #endregion (containerRef.current as any).__monacoEditor = editor; @@ -1247,9 +1265,9 @@ const CodeEditor: React.FC = ({ event.stopPropagation(); if (event.shiftKey) { - editorRef.current?.trigger('keyboard', 'redo', null); + activeEditTargetService.executeAction('redo'); } else { - editorRef.current?.trigger('keyboard', 'undo', null); + activeEditTargetService.executeAction('undo'); } return; } @@ -1257,7 +1275,7 @@ const CodeEditor: React.FC = ({ if (!event.metaKey && event.ctrlKey && lowerKey === 'y') { event.preventDefault(); event.stopPropagation(); - editorRef.current?.trigger('keyboard', 'redo', null); + activeEditTargetService.executeAction('redo'); } }, []); diff --git a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx index ae249469..7fdfc433 100644 --- a/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx +++ b/src/web-ui/src/tools/editor/core/MonacoEditorCore.tsx @@ -15,7 +15,7 @@ import { monacoModelManager } from '../services/MonacoModelManager'; import { themeManager } from '../services/ThemeManager'; import { editorExtensionManager } from '../services/EditorExtensionManager'; import { buildEditorOptions } from '../services/EditorOptionsBuilder'; -import { activeMonacoEditorService } from '../services/ActiveMonacoEditorService'; +import { activeEditTargetService, createMonacoEditTarget } from '../services/ActiveEditTargetService'; import type { MonacoEditorCoreProps } from './types'; import type { EditorExtensionContext } from '../services/EditorExtensionManager'; import type { EditorOptionsOverrides } from '../services/EditorOptionsBuilder'; @@ -148,7 +148,25 @@ export const MonacoEditorCore = forwardRef { + activeEditTargetService.setActiveTarget(editTarget.id); + }); + const blurDisposable = editor.onDidBlurEditorText(() => { + window.setTimeout(() => { + if (editor.hasTextFocus()) { + return; + } + + activeEditTargetService.clearActiveTarget(editTarget.id); + }, 0); + }); + macosEditorBindingCleanupRef.current = () => { + focusDisposable.dispose(); + blurDisposable.dispose(); + unbindEditTarget(); + }; registerEventListeners(editor, model); diff --git a/src/web-ui/src/tools/editor/meditor/components/IREditor.tsx b/src/web-ui/src/tools/editor/meditor/components/IREditor.tsx index 01868ea6..3e55205c 100644 --- a/src/web-ui/src/tools/editor/meditor/components/IREditor.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/IREditor.tsx @@ -3,6 +3,7 @@ import { createLogger } from '@/shared/utils/logger' import { useI18n } from '@/infrastructure/i18n' import { useMarkdown } from '../hooks/useMarkdown' import { useEditorHistory } from '../hooks/useEditorHistory' +import { activeEditTargetService } from '@/tools/editor/services/ActiveEditTargetService' import { MermaidService } from '@/tools/mermaid-editor/services/MermaidService' import { loadLocalImages } from '../utils/loadLocalImages' import { @@ -16,6 +17,16 @@ import { import './IREditor.scss' const log = createLogger('IREditor') +let irEditTargetCounter = 0 + +function isMacOSDesktop(): boolean { + if (typeof window === 'undefined') { + return false + } + + const isTauri = '__TAURI__' in window + return isTauri && typeof navigator.platform === 'string' && navigator.platform.toUpperCase().includes('MAC') +} function escapeHtml(text: string): string { return text @@ -78,6 +89,11 @@ export interface IREditorHandle { isDirty: boolean } +interface SelectionSnapshot { + start: number + end: number +} + const generateStableBlockId = (startLine: number, type: string) => `block-${startLine}-${type}` interface BlockWithoutId { @@ -202,6 +218,11 @@ export const IREditor = React.forwardRef( const editorRef = useRef(null) const blockRefsMap = useRef>(new Map()) const lastValueRef = useRef(value) + const editTargetIdRef = useRef(`markdown-ir-${++irEditTargetCounter}`) + const undoRef = useRef<() => boolean>(() => false) + const redoRef = useRef<() => boolean>(() => false) + const lastRestoredVersionRef = useRef(null) + const nextBlockChangeTransactionRef = useRef<'typing' | 'format'>('typing') const i18nCssVars = useMemo(() => { return { @@ -237,17 +258,58 @@ export const IREditor = React.forwardRef( }, [value, history]) const currentContent = history.content + undoRef.current = history.undo + redoRef.current = history.redo + useEffect(() => { - if (editingBlockId && lastValueRef.current === currentContent) { - return - } - - lastValueRef.current = currentContent const rawBlocks = parseMarkdownToBlocksRaw(currentContent) const newBlocks = updateBlocksSmartly(blocks, rawBlocks) + + if (editingBlockId && editingBlockId !== 'empty-block') { + const matchingBlock = newBlocks.find(block => block.id === editingBlockId) + if (matchingBlock) { + setEditingContent(matchingBlock.content) + } else { + setEditingBlockId(null) + setEditingContent('') + } + } + + if (editingBlockId === 'empty-block') { + setEditingContent(currentContent) + } + + lastValueRef.current = currentContent setBlocks(newBlocks) }, [currentContent, editingBlockId]) // blocks intentionally excluded to avoid loops + useEffect(() => { + const selection = history.selection + if (!selection || !editingBlockId) { + return + } + + if (lastRestoredVersionRef.current === history.currentVersionId) { + return + } + + const textarea = editorRef.current?.querySelector('textarea') + if (!textarea) { + return + } + + lastRestoredVersionRef.current = history.currentVersionId + + requestAnimationFrame(() => { + if (!editorRef.current?.contains(textarea)) { + return + } + + textarea.focus() + textarea.setSelectionRange(selection.start, selection.end) + }) + }, [history.currentVersionId, history.selection, editingBlockId]) + const scrollToLine = useCallback((line: number, highlight: boolean = true) => { const targetBlock = blocks.find( block => line >= block.startLine + 1 && line <= block.endLine + 1 @@ -303,6 +365,35 @@ export const IREditor = React.forwardRef( isDirty: history.isDirty }), [scrollToLine, history]) + useEffect(() => { + const targetId = editTargetIdRef.current + + return activeEditTargetService.bindTarget({ + id: targetId, + kind: 'markdown-ir', + focus: () => { + const activeTextarea = editorRef.current?.querySelector('textarea') + if (activeTextarea) { + activeTextarea.focus() + return + } + + editorRef.current?.focus() + }, + hasTextFocus: () => { + const root = editorRef.current + const activeElement = typeof document !== 'undefined' ? document.activeElement : null + return !!root && !!activeElement && root.contains(activeElement) + }, + undo: () => undoRef.current(), + redo: () => redoRef.current(), + containsElement: (element) => { + const root = editorRef.current + return !!root && !!element && root.contains(element) + }, + }) + }, []) + const handleBlockClick = useCallback((blockId: string) => { if (readonly) return @@ -313,8 +404,10 @@ export const IREditor = React.forwardRef( } }, [readonly, blocks]) - const handleBlockContentChange = useCallback((newContent: string) => { + const handleBlockContentChange = useCallback((newContent: string, selection?: SelectionSnapshot) => { setEditingContent(newContent) + const transactionType = nextBlockChangeTransactionRef.current + nextBlockChangeTransactionRef.current = 'typing' if (editingBlockId && editingBlockId !== 'empty-block') { const block = blocks.find(b => b.id === editingBlockId) @@ -324,7 +417,11 @@ export const IREditor = React.forwardRef( const after = lines.slice(block.endLine + 1).join('\n') const newValue = [before, newContent, after].filter(s => s !== '').join('\n') lastValueRef.current = newValue - history.pushChange(newValue) + history.pushChange(newValue, { + selectionStart: selection?.start, + selectionEnd: selection?.end, + transactionType + }) } } }, [editingBlockId, blocks, currentContent, history]) @@ -363,25 +460,43 @@ export const IREditor = React.forwardRef( } if (modKey && e.key === 'z' && !e.shiftKey) { - e.preventDefault() - if (editingBlockId) { - setEditingBlockId(null) - setEditingContent('') + if (isMacOSDesktop()) { + e.preventDefault() + return } + + e.preventDefault() history.undo() return } if ((modKey && e.key === 'z' && e.shiftKey) || (e.ctrlKey && e.key === 'y')) { - e.preventDefault() - if (editingBlockId) { - setEditingBlockId(null) - setEditingContent('') + if (isMacOSDesktop()) { + e.preventDefault() + return } + + e.preventDefault() history.redo() return } }, [currentContent, blocks, editingBlockId, history]) + + const handleFocusCapture = useCallback(() => { + activeEditTargetService.setActiveTarget(editTargetIdRef.current) + }, []) + + const handleBlurCapture = useCallback(() => { + window.setTimeout(() => { + const root = editorRef.current + const activeElement = typeof document !== 'undefined' ? document.activeElement : null + if (root && activeElement && root.contains(activeElement)) { + return + } + + activeEditTargetService.clearActiveTarget(editTargetIdRef.current) + }, 0) + }, []) const handleBlockKeyDown = useCallback((e: React.KeyboardEvent) => { const modKey = isModKey(e) @@ -408,7 +523,7 @@ export const IREditor = React.forwardRef( setEditingBlockId(null) setEditingContent('') - history.pushChange(newValue) + history.pushChange(newValue, { transactionType: 'structure' }) } return } else if (currentBlockIndex > 0 && value.length > 0) { @@ -424,7 +539,11 @@ export const IREditor = React.forwardRef( setEditingBlockId(null) setEditingContent('') - history.pushChange(newValue) + history.pushChange(newValue, { + selectionStart: prevBlock.content.length, + selectionEnd: prevBlock.content.length, + transactionType: 'structure' + }) setTimeout(() => { const newBlocks = parseMarkdownToBlocksRaw(newValue) @@ -450,7 +569,7 @@ export const IREditor = React.forwardRef( setEditingBlockId(null) setEditingContent('') - history.pushChange(newValue) + history.pushChange(newValue, { transactionType: 'structure' }) } return } @@ -461,6 +580,7 @@ export const IREditor = React.forwardRef( ? outdentLines(value, selectionStart, selectionEnd) : indentLines(value, selectionStart, selectionEnd) + nextBlockChangeTransactionRef.current = 'format' textarea.value = result.text textarea.setSelectionRange(result.selectionStart, result.selectionEnd) const event = new Event('input', { bubbles: true }) @@ -471,6 +591,7 @@ export const IREditor = React.forwardRef( if (modKey && e.key === 'b') { e.preventDefault() const result = toggleBold(value, selectionStart, selectionEnd) + nextBlockChangeTransactionRef.current = 'format' textarea.value = result.text textarea.setSelectionRange(result.selectionStart, result.selectionEnd) const event = new Event('input', { bubbles: true }) @@ -481,6 +602,7 @@ export const IREditor = React.forwardRef( if (modKey && e.key === 'i') { e.preventDefault() const result = toggleItalic(value, selectionStart, selectionEnd) + nextBlockChangeTransactionRef.current = 'format' textarea.value = result.text textarea.setSelectionRange(result.selectionStart, result.selectionEnd) const event = new Event('input', { bubbles: true }) @@ -491,6 +613,7 @@ export const IREditor = React.forwardRef( if (modKey && e.key === 'k') { e.preventDefault() const result = insertLink(value, selectionStart, selectionEnd) + nextBlockChangeTransactionRef.current = 'format' textarea.value = result.text textarea.setSelectionRange(result.selectionStart, result.selectionEnd) const event = new Event('input', { bubbles: true }) @@ -500,7 +623,11 @@ export const IREditor = React.forwardRef( }, [handleKeyDown, editingBlockId, blocks, currentContent, history]) const handleEmptyBlockChange = useCallback((e: React.ChangeEvent) => { - history.pushChange(e.target.value) + history.pushChange(e.target.value, { + selectionStart: e.target.selectionStart, + selectionEnd: e.target.selectionEnd, + transactionType: 'typing' + }) }, [history]) const handleEmptyBlockKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -517,7 +644,11 @@ export const IREditor = React.forwardRef( ? outdentLines(value, selectionStart, selectionEnd) : indentLines(value, selectionStart, selectionEnd) - history.pushChange(result.text) + history.pushChange(result.text, { + selectionStart: result.selectionStart, + selectionEnd: result.selectionEnd, + transactionType: 'format' + }) setTimeout(() => { textarea.setSelectionRange(result.selectionStart, result.selectionEnd) }, 0) @@ -527,7 +658,11 @@ export const IREditor = React.forwardRef( if (modKey && e.key === 'b') { e.preventDefault() const result = toggleBold(value, selectionStart, selectionEnd) - history.pushChange(result.text) + history.pushChange(result.text, { + selectionStart: result.selectionStart, + selectionEnd: result.selectionEnd, + transactionType: 'format' + }) setTimeout(() => { textarea.setSelectionRange(result.selectionStart, result.selectionEnd) }, 0) @@ -537,7 +672,11 @@ export const IREditor = React.forwardRef( if (modKey && e.key === 'i') { e.preventDefault() const result = toggleItalic(value, selectionStart, selectionEnd) - history.pushChange(result.text) + history.pushChange(result.text, { + selectionStart: result.selectionStart, + selectionEnd: result.selectionEnd, + transactionType: 'format' + }) setTimeout(() => { textarea.setSelectionRange(result.selectionStart, result.selectionEnd) }, 0) @@ -547,7 +686,11 @@ export const IREditor = React.forwardRef( if (modKey && e.key === 'k') { e.preventDefault() const result = insertLink(value, selectionStart, selectionEnd) - history.pushChange(result.text) + history.pushChange(result.text, { + selectionStart: result.selectionStart, + selectionEnd: result.selectionEnd, + transactionType: 'format' + }) setTimeout(() => { textarea.setSelectionRange(result.selectionStart, result.selectionEnd) }, 0) @@ -567,7 +710,15 @@ export const IREditor = React.forwardRef( }, [onBlur]) return ( -
+
{editingBlockId === 'empty-block' ? (