diff --git a/README.md b/README.md index a7387d8..dd8d50e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ VoidRaft is a modern developer-focused text editor that allows you to record, or - Smart language detection - Automatically recognizes code block language types - Code formatting - Built-in Prettier support for one-click code beautification - Block editing mode - Split content into independent code blocks, each with different language settings +- Multi-window support - edit multiple documents at the same time ### Modern Interface @@ -53,6 +54,7 @@ cd voidraft # Install frontend dependencies cd frontend npm install +npm run build cd .. # Start development server @@ -117,11 +119,8 @@ Voidraft/ ### Planned Features - [ ] Custom themes - Customize editor themes -- [ ] Multi-window support - Support editing multiple documents simultaneously +- ✅ Multi-window support - Support editing multiple documents simultaneously - [ ] Enhanced clipboard - Monitor and manage clipboard history - - Automatic text content saving - - Image content support - - History management - [ ] Data synchronization - Cloud backup for configurations and documents - [ ] Extension system - Support for custom plugins diff --git a/README_ZH.md b/README_ZH.md index ceba226..127e74a 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -14,6 +14,7 @@ Voidraft 是一个现代化的开发者专用文本编辑器,让你能够随 - 智能语言检测 - 自动识别代码块语言类型 - 代码格式化 - 内置 Prettier 支持,一键美化代码 - 块状编辑模式 - 将内容分割为独立的代码块,每个块可设置不同语言 +- 支持多窗口 - 同时编辑多个文档 ### 现代化界面 @@ -54,6 +55,7 @@ cd voidraft # 安装前端依赖 cd frontend npm install +npm run build cd .. # 启动开发服务器 @@ -118,11 +120,8 @@ Voidraft/ ### 计划添加的功能 - [ ] 自定义主题 - 自定义编辑器主题 -- [ ] 多窗口支持 - 支持同时编辑多个文档 +- ✅ 多窗口支持 - 支持同时编辑多个文档 - [ ] 剪切板增强 - 监听和管理剪切板历史 - - 文本内容自动保存 - - 图片内容支持 - - 历史记录管理 - [ ] 数据同步 - 配置和文档云端备份 - [ ] 扩展系统 - 支持自定义插件 diff --git a/frontend/bindings/voidraft/internal/services/index.ts b/frontend/bindings/voidraft/internal/services/index.ts index 697cd4c..6647c72 100644 --- a/frontend/bindings/voidraft/internal/services/index.ts +++ b/frontend/bindings/voidraft/internal/services/index.ts @@ -14,6 +14,7 @@ import * as StartupService from "./startupservice.js"; import * as SystemService from "./systemservice.js"; import * as TranslationService from "./translationservice.js"; import * as TrayService from "./trayservice.js"; +import * as WindowService from "./windowservice.js"; export { ConfigService, DatabaseService, @@ -27,7 +28,8 @@ export { StartupService, SystemService, TranslationService, - TrayService + TrayService, + WindowService }; export * from "./models.js"; diff --git a/frontend/bindings/voidraft/internal/services/models.ts b/frontend/bindings/voidraft/internal/services/models.ts index 9acb2da..f034164 100644 --- a/frontend/bindings/voidraft/internal/services/models.ts +++ b/frontend/bindings/voidraft/internal/services/models.ts @@ -5,6 +5,10 @@ // @ts-ignore: Unused imports import {Create as $Create} from "@wailsio/runtime"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js"; + /** * MemoryStats 内存统计信息 */ @@ -197,3 +201,43 @@ export class SelfUpdateResult { return new SelfUpdateResult($$parsedSource as Partial); } } + +/** + * WindowInfo 窗口信息 + */ +export class WindowInfo { + "Window": application$0.WebviewWindow | null; + "DocumentID": number; + "Title": string; + + /** Creates a new WindowInfo instance. */ + constructor($$source: Partial = {}) { + if (!("Window" in $$source)) { + this["Window"] = null; + } + if (!("DocumentID" in $$source)) { + this["DocumentID"] = 0; + } + if (!("Title" in $$source)) { + this["Title"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new WindowInfo instance from a string or object. + */ + static createFrom($$source: any = {}): WindowInfo { + const $$createField0_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("Window" in $$parsedSource) { + $$parsedSource["Window"] = $$createField0_0($$parsedSource["Window"]); + } + return new WindowInfo($$parsedSource as Partial); + } +} + +// Private type creation functions +const $$createType0 = application$0.WebviewWindow.createFrom; +const $$createType1 = $Create.Nullable($$createType0); diff --git a/frontend/bindings/voidraft/internal/services/windowservice.ts b/frontend/bindings/voidraft/internal/services/windowservice.ts new file mode 100644 index 0000000..01a7db2 --- /dev/null +++ b/frontend/bindings/voidraft/internal/services/windowservice.ts @@ -0,0 +1,59 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * WindowService 窗口管理服务 + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Call as $Call, Create as $Create} from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * GetOpenWindows 获取所有打开的窗口信息 + */ +export function GetOpenWindows(): Promise<$models.WindowInfo[]> & { cancel(): void } { + let $resultPromise = $Call.ByID(1464997251) as any; + let $typingPromise = $resultPromise.then(($result: any) => { + return $$createType1($result); + }) as any; + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * IsDocumentWindowOpen 检查指定文档的窗口是否已打开 + */ +export function IsDocumentWindowOpen(documentID: number): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(1735611839, documentID) as any; + return $resultPromise; +} + +/** + * OpenDocumentWindow 为指定文档ID打开新窗口 + */ +export function OpenDocumentWindow(documentID: number): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(494716471, documentID) as any; + return $resultPromise; +} + +/** + * SetAppReferences 设置应用和主窗口引用 + */ +export function SetAppReferences(app: application$0.App | null, mainWindow: application$0.WebviewWindow | null): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(1120840759, app, mainWindow) as any; + return $resultPromise; +} + +// Private type creation functions +const $$createType0 = $models.WindowInfo.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/frontend/src/components/titlebar/LinuxTitleBar.vue b/frontend/src/components/titlebar/LinuxTitleBar.vue index 87ca4e3..a510402 100644 --- a/frontend/src/components/titlebar/LinuxTitleBar.vue +++ b/frontend/src/components/titlebar/LinuxTitleBar.vue @@ -4,7 +4,7 @@
voidraft
-
voidraft
+
{{ titleText }}
@@ -46,12 +46,15 @@ @@ -300,79 +347,103 @@ onUnmounted(() => {
- - +
- +
-
- + {{ item.title }}
- +
{{ item.title }}
-
{{ formatTime(item.updatedAt) }}
+ +
+ {{ t('toolbar.alreadyOpenInNewWindow') }} +
+
{{ formatTime(item.updatedAt) }}
- +
- +
+ + -
- +
{{ t('toolbar.noDocumentFound') }}
- +
{{ t('toolbar.loading') }} @@ -399,7 +470,7 @@ onUnmounted(() => { \ No newline at end of file + +@keyframes fadeInOut { + 0% { + opacity: 1; + } + 70% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + \ No newline at end of file diff --git a/frontend/src/components/toolbar/Toolbar.vue b/frontend/src/components/toolbar/Toolbar.vue index 8224b68..8188f01 100644 --- a/frontend/src/components/toolbar/Toolbar.vue +++ b/frontend/src/components/toolbar/Toolbar.vue @@ -4,6 +4,7 @@ import {onMounted, onUnmounted, ref, watch, computed} from 'vue'; import {useConfigStore} from '@/stores/configStore'; import {useEditorStore} from '@/stores/editorStore'; import {useUpdateStore} from '@/stores/updateStore'; +import {useWindowStore} from '@/stores/windowStore'; import * as runtime from '@wailsio/runtime'; import {useRouter} from 'vue-router'; import BlockLanguageSelector from './BlockLanguageSelector.vue'; @@ -15,20 +16,23 @@ import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode const editorStore = useEditorStore(); const configStore = useConfigStore(); const updateStore = useUpdateStore(); +const windowStore = useWindowStore(); const {t} = useI18n(); const router = useRouter(); // 当前块是否支持格式化的响应式状态 const canFormatCurrentBlock = ref(false); -// 窗口置顶状态管理 +// 窗口置顶状态管理(仅当前窗口,不同步到配置文件) +const isCurrentWindowOnTop = ref(false); + const setWindowAlwaysOnTop = async (isTop: boolean) => { await runtime.Window.SetAlwaysOnTop(isTop); }; const toggleAlwaysOnTop = async () => { - await configStore.toggleAlwaysOnTop(); - await runtime.Window.SetAlwaysOnTop(configStore.config.general.alwaysOnTop); + isCurrentWindowOnTop.value = !isCurrentWindowOnTop.value; + await runtime.Window.SetAlwaysOnTop(isCurrentWindowOnTop.value); }; // 跳转到设置页面 @@ -136,20 +140,12 @@ onUnmounted(() => { cleanupListeners = []; }); -// 监听置顶设置变化 -watch( - () => configStore.config.general.alwaysOnTop, - async (newValue) => { - if (isLoaded.value) { - await runtime.Window.SetAlwaysOnTop(newValue); - } - } -); - -// 组件加载后应用置顶设置 +// 组件加载后初始化置顶状态 watch(isLoaded, async (loaded) => { - if (loaded && configStore.config.general.alwaysOnTop) { - await setWindowAlwaysOnTop(true); + if (loaded) { + // 初始化时从配置文件读取置顶状态 + isCurrentWindowOnTop.value = configStore.config.general.alwaysOnTop; + await setWindowAlwaysOnTop(isCurrentWindowOnTop.value); } }); @@ -197,7 +193,7 @@ const updateButtonTitle = computed(() => { - + @@ -265,7 +261,7 @@ const updateButtonTitle = computed(() => {
@@ -276,7 +272,7 @@ const updateButtonTitle = computed(() => {
-
- 预览: - {{ hotkeyPreview || '无' }} + {{ t('settings.hotkeyPreview') }} + {{ hotkeyPreview || t('settings.none') }}
diff --git a/internal/events/tray_events.go b/internal/events/tray_events.go index 8941569..0243c6c 100644 --- a/internal/events/tray_events.go +++ b/internal/events/tray_events.go @@ -33,13 +33,13 @@ func RegisterTrayEvents(app *application.App, systray *application.SystemTray, m // RegisterTrayMenuEvents 注册系统托盘菜单事件 func RegisterTrayMenuEvents(app *application.App, menu *application.Menu, mainWindow *application.WebviewWindow) { - menu.Add("主窗口").OnClick(func(data *application.Context) { + menu.Add("Main window").OnClick(func(data *application.Context) { mainWindow.Show() }) menu.AddSeparator() - menu.Add("退出").OnClick(func(data *application.Context) { + menu.Add("Quit").OnClick(func(data *application.Context) { app.Quit() }) } diff --git a/internal/services/service_manager.go b/internal/services/service_manager.go index 66d7c84..d488208 100644 --- a/internal/services/service_manager.go +++ b/internal/services/service_manager.go @@ -14,6 +14,7 @@ type ServiceManager struct { databaseService *DatabaseService sqliteService *sqlite.Service documentService *DocumentService + windowService *WindowService migrationService *MigrationService systemService *SystemService hotkeyService *HotkeyService @@ -47,6 +48,9 @@ func NewServiceManager() *ServiceManager { // 初始化文档服务 documentService := NewDocumentService(databaseService, logger) + // 初始化窗口服务 + windowService := NewWindowService(logger, documentService) + // 初始化系统服务 systemService := NewSystemService(logger) @@ -98,6 +102,7 @@ func NewServiceManager() *ServiceManager { databaseService: databaseService, sqliteService: sqliteService, documentService: documentService, + windowService: windowService, migrationService: migrationService, systemService: systemService, hotkeyService: hotkeyService, @@ -113,13 +118,13 @@ func NewServiceManager() *ServiceManager { } // GetServices 获取所有wails服务列表 -// 注意:服务启动顺序很重要,DatabaseService 必须在依赖数据库的服务之前启动 func (sm *ServiceManager) GetServices() []application.Service { services := []application.Service{ application.NewService(sm.configService), - application.NewService(sm.sqliteService), // SQLite服务必须在数据库服务之前初始化 - application.NewService(sm.databaseService), // 数据库服务必须在依赖它的服务之前初始化 + application.NewService(sm.sqliteService), + application.NewService(sm.databaseService), application.NewService(sm.documentService), + application.NewService(sm.windowService), application.NewService(sm.keyBindingService), application.NewService(sm.extensionService), application.NewService(sm.migrationService), @@ -193,3 +198,13 @@ func (sm *ServiceManager) GetDatabaseService() *DatabaseService { func (sm *ServiceManager) GetSQLiteService() *sqlite.Service { return sm.sqliteService } + +// GetWindowService 获取窗口服务实例 +func (sm *ServiceManager) GetWindowService() *WindowService { + return sm.windowService +} + +// GetDocumentService 获取文档服务实例 +func (sm *ServiceManager) GetDocumentService() *DocumentService { + return sm.documentService +} diff --git a/internal/services/window_service.go b/internal/services/window_service.go new file mode 100644 index 0000000..64c7b48 --- /dev/null +++ b/internal/services/window_service.go @@ -0,0 +1,149 @@ +package services + +import ( + "fmt" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/wailsapp/wails/v3/pkg/services/log" +) + +// WindowInfo 窗口信息 +type WindowInfo struct { + Window *application.WebviewWindow + DocumentID int64 + Title string +} + +// WindowService 窗口管理服务 +type WindowService struct { + logger *log.Service + documentService *DocumentService + app *application.App + mainWindow *application.WebviewWindow + windows map[int64]*WindowInfo // documentID -> WindowInfo + mu sync.RWMutex +} + +// NewWindowService 创建新的窗口服务实例 +func NewWindowService(logger *log.Service, documentService *DocumentService) *WindowService { + if logger == nil { + logger = log.New() + } + + return &WindowService{ + logger: logger, + documentService: documentService, + windows: make(map[int64]*WindowInfo), + } +} + +// SetAppReferences 设置应用和主窗口引用 +func (ws *WindowService) SetAppReferences(app *application.App, mainWindow *application.WebviewWindow) { + ws.app = app + ws.mainWindow = mainWindow +} + +// OpenDocumentWindow 为指定文档ID打开新窗口 +func (ws *WindowService) OpenDocumentWindow(documentID int64) error { + ws.mu.Lock() + defer ws.mu.Unlock() + + // 检查窗口是否已经存在 + if windowInfo, exists := ws.windows[documentID]; exists { + // 窗口已存在,显示并聚焦 + windowInfo.Window.Show() + windowInfo.Window.Restore() + windowInfo.Window.Focus() + return nil + } + + // 获取文档信息 + doc, err := ws.documentService.GetDocumentByID(documentID) + if err != nil { + return fmt.Errorf("failed to get document: %w", err) + } + if doc == nil { + return fmt.Errorf("document not found: %d", documentID) + } + + // 创建新窗口 + newWindow := ws.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: fmt.Sprintf("voidraft - %s", doc.Title), + Width: 700, + Height: 800, + Hidden: false, + Frameless: true, + DevToolsEnabled: false, + DefaultContextMenuDisabled: false, + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 50, + Backdrop: application.MacBackdropTranslucent, + TitleBar: application.MacTitleBarHiddenInset, + }, + Windows: application.WindowsWindow{ + Theme: application.SystemDefault, + }, + BackgroundColour: application.NewRGB(27, 38, 54), + URL: fmt.Sprintf("/?documentId=%d", documentID), + }) + + newWindow.Center() + + ws.app.Window.Add(newWindow) + + // 保存窗口信息 + windowInfo := &WindowInfo{ + Window: newWindow, + DocumentID: documentID, + Title: doc.Title, + } + ws.windows[documentID] = windowInfo + + // 注册窗口关闭事件 + ws.registerWindowEvents(newWindow, documentID) + + return nil +} + +// registerWindowEvents 注册窗口事件 +func (ws *WindowService) registerWindowEvents(window *application.WebviewWindow, documentID int64) { + // 注册窗口关闭事件 + window.RegisterHook(events.Common.WindowClosing, func(event *application.WindowEvent) { + ws.onWindowClosing(documentID) + }) +} + +// onWindowClosing 处理窗口关闭事件 +func (ws *WindowService) onWindowClosing(documentID int64) { + ws.mu.Lock() + defer ws.mu.Unlock() + windowInfo, exists := ws.windows[documentID] + if exists { + windowInfo.Window.Close() + delete(ws.windows, documentID) + } + +} + +// GetOpenWindows 获取所有打开的窗口信息 +func (ws *WindowService) GetOpenWindows() []WindowInfo { + ws.mu.RLock() + defer ws.mu.RUnlock() + + var windows []WindowInfo + for _, windowInfo := range ws.windows { + windows = append(windows, *windowInfo) + } + return windows +} + +// IsDocumentWindowOpen 检查指定文档的窗口是否已打开 +func (ws *WindowService) IsDocumentWindowOpen(documentID int64) bool { + ws.mu.RLock() + defer ws.mu.RUnlock() + + _, exists := ws.windows[documentID] + return exists +} diff --git a/main.go b/main.go index dc3912b..e141319 100644 --- a/main.go +++ b/main.go @@ -32,7 +32,6 @@ func main() { 0x0e, 0x0f, 0x0c, 0x0d, 0x0a, 0x0b, 0x08, 0x09, 0x06, 0x07, 0x04, 0x05, 0x02, 0x03, 0x00, 0x01, } - var window *application.WebviewWindow // Create a new Wails application by providing the necessary options. // Variables 'Name' and 'Description' are for application metadata. // 'Assets' configures the asset server with the 'FS' variable pointing to the frontend files. @@ -52,18 +51,6 @@ func main() { SingleInstance: &application.SingleInstanceOptions{ UniqueID: "com.voidraft", EncryptionKey: encryptionKey, - OnSecondInstanceLaunch: func(data application.SecondInstanceData) { - if window != nil { - window.EmitEvent("secondInstanceLaunched", data) - window.Restore() - window.Focus() - } - log.Printf("Second instance launched with args: %v\n", data.Args) - log.Printf("Working directory: %s\n", data.WorkingDir) - if data.AdditionalData != nil { - log.Printf("Additional data: %v\n", data.AdditionalData) - } - }, AdditionalData: map[string]string{ "launchtime": time.Now().Local().String(), }, @@ -100,6 +87,10 @@ func main() { trayService := serviceManager.GetTrayService() trayService.SetAppReferences(app, mainWindow) + // 获取窗口服务并设置应用引用 + windowService := serviceManager.GetWindowService() + windowService.SetAppReferences(app, mainWindow) + // 设置系统托盘 systray.SetupSystemTray(app, mainWindow, assets, trayService) @@ -114,9 +105,6 @@ func main() { dialogService := serviceManager.GetDialogService() dialogService.SetWindow(mainWindow) - // 设置全局变量供单实例处理使用 - window = mainWindow - // Create a goroutine that emits an event containing the current time every second. // The frontend can listen to this event and update the UI accordingly. go func() {