diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 778bfb5..d07dea3 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,10 @@ "windows": ["main"], "permissions": [ "core:default", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-start-dragging", "opener:default", "dialog:default" ] diff --git a/src/App.vue b/src/App.vue index 9647810..ee9da51 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ import {computed, ref, watch, onMounted, onUnmounted} from "vue"; import {listen, type EventCallback, type EventName} from "@tauri-apps/api/event"; import {invoke, isTauri} from "@tauri-apps/api/core"; +import {getCurrentWindow} from "@tauri-apps/api/window"; import MarkdownEditor from "./components/MarkdownEditor.vue"; import DirectorySidebar from "./components/DirectorySidebar.vue"; import OutlinePanel from "./components/OutlinePanel.vue"; @@ -135,10 +136,105 @@ const showMoreMenu = ref(false); const settingsRequest = ref(0); const settingsInitialPage = ref<"about" | "editor">("about"); const markdownEditorRef = ref<{ flushPendingChanges: () => string | undefined } | null>(null); +const isWindowMaximized = ref(false); const showStartActions = computed(() => { return !folderPath.value && !currentFilePath.value && !hasDraftDocument.value; }); +type TitlebarDragState = { + startX: number; + startY: number; +}; + +const TITLEBAR_DRAG_THRESHOLD = 4; +let titlebarDragState: TitlebarDragState | null = null; +let unlistenWindowResize: (() => void) | null = null; + +function isInteractiveTitlebarTarget(target: EventTarget | null) { + if (!(target instanceof Element)) return false; + return Boolean(target.closest("button, a, input, textarea, select, [data-no-window-drag]")); +} + +async function refreshWindowMaximized() { + if (!isTauri()) return; + try { + isWindowMaximized.value = await getCurrentWindow().isMaximized(); + } catch (error) { + console.error("Failed to read window state:", error); + } +} + +async function minimizeWindow() { + if (!isTauri()) return; + try { + await getCurrentWindow().minimize(); + } catch (error) { + console.error("Failed to minimize window:", error); + } +} + +async function toggleWindowMaximize() { + if (!isTauri()) return; + try { + const appWindow = getCurrentWindow(); + await appWindow.toggleMaximize(); + isWindowMaximized.value = await appWindow.isMaximized(); + } catch (error) { + console.error("Failed to toggle window maximize:", error); + } +} + +async function closeWindow() { + if (!isTauri()) return; + try { + await getCurrentWindow().close(); + } catch (error) { + console.error("Failed to close window:", error); + } +} + +function removeTitlebarDragListeners() { + window.removeEventListener("mousemove", onTitlebarMouseMove); + window.removeEventListener("mouseup", removeTitlebarDragListeners); + titlebarDragState = null; +} + +function onTitlebarMouseDown(event: MouseEvent) { + if (event.button !== 0 || event.detail > 1 || isInteractiveTitlebarTarget(event.target)) return; + + event.preventDefault(); + titlebarDragState = { + startX: event.screenX, + startY: event.screenY, + }; + window.addEventListener("mousemove", onTitlebarMouseMove); + window.addEventListener("mouseup", removeTitlebarDragListeners, {once: true}); +} + +function onTitlebarMouseMove(event: MouseEvent) { + if (!titlebarDragState) return; + + const movedX = Math.abs(event.screenX - titlebarDragState.startX); + const movedY = Math.abs(event.screenY - titlebarDragState.startY); + if (movedX < TITLEBAR_DRAG_THRESHOLD && movedY < TITLEBAR_DRAG_THRESHOLD) return; + + event.preventDefault(); + removeTitlebarDragListeners(); + if (!isTauri()) return; + + void getCurrentWindow().startDragging().catch((error) => { + console.error("Failed to start window drag:", error); + }); +} + +function onTitlebarDoubleClick(event: MouseEvent) { + if (isInteractiveTitlebarTarget(event.target)) return; + + event.preventDefault(); + removeTitlebarDragListeners(); + void toggleWindowMaximize(); +} + function openSettings(page: "about" | "editor" = "about") { settingsInitialPage.value = page; settingsRequest.value += 1; @@ -181,11 +277,26 @@ onMounted(() => { applyAppTheme(appTheme.value); applyZoomCssVars(); window.addEventListener("wheel", onEditorWheel, {passive: false}); + if (isTauri()) { + void refreshWindowMaximized(); + void getCurrentWindow().onResized(() => { + void refreshWindowMaximized(); + }).then((unlisten) => { + unlistenWindowResize = unlisten; + }).catch((error) => { + console.error("Failed to listen for window resize:", error); + }); + } void restoreLastWorkspace(); }); onUnmounted(() => { window.removeEventListener("wheel", onEditorWheel); + removeTitlebarDragListeners(); + if (unlistenWindowResize) { + unlistenWindowResize(); + unlistenWindowResize = null; + } if (zoomIndicatorTimer) clearTimeout(zoomIndicatorTimer); }); @@ -478,28 +589,45 @@ registerTauriListener("menu-event", (event) => {
- +
- + +
+
+ yurou +
-
+
+
{{ showStartActions ? '未选择工作目录' : currentFilePath ? getFileName(currentFilePath) : '未命名文档' }} +
-
+
+ + +
+ + + +
@@ -657,7 +813,7 @@ registerTauriListener("menu-event", (event) => { color: var(--app-text); } -.editor-toolbar { +.editor-titlebar { background: var(--app-bg); border-bottom: 1px solid var(--app-border-strong); } @@ -686,6 +842,28 @@ registerTauriListener("menu-event", (event) => { /* ── Toolbar ───────────────────────────────────────────────────── */ +.titlebar-drag-surface { + height: 100%; + min-width: 32px; + display: flex; + align-items: center; +} + +.titlebar-brand { + height: 100%; + min-width: 68px; + display: inline-flex; + align-items: center; + font-size: 12px; + font-weight: 600; + letter-spacing: 0; +} + +.titlebar-file-title { + width: 100%; + line-height: 1.2; +} + .toolbar-btn { display: inline-flex; align-items: center; @@ -711,6 +889,43 @@ registerTauriListener("menu-event", (event) => { background-color: var(--app-surface-muted); } +.window-controls { + color: var(--app-muted); +} + +.window-control-btn { + width: 46px; + height: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 0; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 15px; + line-height: 1; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.window-control-btn:hover { + background-color: var(--app-hover); + color: var(--app-text); +} + +.window-control-btn:focus-visible { + outline: 1px solid var(--app-accent); + outline-offset: -3px; +} + +.window-control-close:hover { + background-color: #c42b1c; + color: #ffffff; +} + .mode-toggle { display: inline-flex; align-items: center;