|
| 1 | +import { useEffect, useState } from "react"; |
| 2 | +import { useIsomorphicEffect } from "@mantine/hooks"; |
| 3 | +import { getCurrentWindow } from "@tauri-apps/api/window"; |
| 4 | +import { isTauri } from "@tauri-apps/api/core"; |
| 5 | +import MinimizeIcon from "@mui/icons-material/Minimize"; |
| 6 | +import CropSquareIcon from "@mui/icons-material/CropSquare"; |
| 7 | +import FilterNoneIcon from "@mui/icons-material/FilterNone"; |
| 8 | +import CloseIcon from "@mui/icons-material/Close"; |
| 9 | +import { getDesktopOs, DesktopOs } from "@app/services/platformService"; |
| 10 | +import styles from "@app/components/WindowTitleBar.module.css"; |
| 11 | +// Desktop-only skin that reserves the controls' corner across core layout |
| 12 | +// surfaces; every rule is gated by the data-window-controls flag set below. |
| 13 | +import "@app/components/windowChrome.css"; |
| 14 | + |
| 15 | +// Seed from the UA so the bar (and its reserved height) is present on the first |
| 16 | +// frame on Windows, avoiding a layout shift. getDesktopOs() confirms it right |
| 17 | +// after via the Rust command. |
| 18 | +const seedIsWindows = |
| 19 | + typeof navigator !== "undefined" && /Windows/i.test(navigator.userAgent); |
| 20 | + |
| 21 | +/** |
| 22 | + * Custom window controls for the Windows desktop build. The native caption is |
| 23 | + * removed in Rust (decorations:false), so this draws minimize/maximize/close as |
| 24 | + * a fixed overlay pinned to the top-right corner — the rail and panels run all |
| 25 | + * the way to the window edge, and the app chrome reserves the corner via the |
| 26 | + * data-window-controls flag this sets on <html> (consumed by windowChrome.css). |
| 27 | + * Renders nothing on macOS/Linux (native decorations kept); not bundled in the |
| 28 | + * browser build. tao provides edge/corner resize for the undecorated window, so |
| 29 | + * no manual resize handles are needed here. |
| 30 | + */ |
| 31 | +export function WindowTitleBar() { |
| 32 | + const [isWindows, setIsWindows] = useState(seedIsWindows); |
| 33 | + const [maximized, setMaximized] = useState(false); |
| 34 | + const active = isWindows && isTauri(); |
| 35 | + |
| 36 | + // Confirm the OS authoritatively (the UA seed is only a first-frame guess). |
| 37 | + useEffect(() => { |
| 38 | + if (!isTauri()) { |
| 39 | + setIsWindows(false); |
| 40 | + return; |
| 41 | + } |
| 42 | + let mounted = true; |
| 43 | + void getDesktopOs().then((os) => { |
| 44 | + if (mounted) setIsWindows(os === DesktopOs.Windows); |
| 45 | + }); |
| 46 | + return () => { |
| 47 | + mounted = false; |
| 48 | + }; |
| 49 | + }, []); |
| 50 | + |
| 51 | + // Flag the custom chrome on <html> so windowChrome.css can reserve the |
| 52 | + // controls' corner across the app. Set only when active (Windows desktop); |
| 53 | + // absent otherwise, so the skin is inert on macOS/Linux. Layout effect so it |
| 54 | + // lands before paint. |
| 55 | + useIsomorphicEffect(() => { |
| 56 | + const root = document.documentElement; |
| 57 | + if (active) { |
| 58 | + root.setAttribute("data-window-controls", "custom"); |
| 59 | + } else { |
| 60 | + root.removeAttribute("data-window-controls"); |
| 61 | + } |
| 62 | + return () => { |
| 63 | + root.removeAttribute("data-window-controls"); |
| 64 | + }; |
| 65 | + }, [active]); |
| 66 | + |
| 67 | + // Keep the maximize/restore icon in sync with the actual window state |
| 68 | + // (double-click, snap, or the button itself all change it). |
| 69 | + useEffect(() => { |
| 70 | + if (!active) return; |
| 71 | + const appWindow = getCurrentWindow(); |
| 72 | + let unlisten: (() => void) | undefined; |
| 73 | + void appWindow.isMaximized().then(setMaximized); |
| 74 | + void appWindow |
| 75 | + .onResized(() => { |
| 76 | + void appWindow.isMaximized().then(setMaximized); |
| 77 | + }) |
| 78 | + .then((u) => { |
| 79 | + unlisten = u; |
| 80 | + }); |
| 81 | + return () => unlisten?.(); |
| 82 | + }, [active]); |
| 83 | + |
| 84 | + // Let the window be dragged, and double-click-maximized, from any |
| 85 | + // non-interactive spot in the top strip. data-tauri-drag-region only fires on |
| 86 | + // bare container backgrounds, leaving most of a busy toolbar undraggable, so a |
| 87 | + // document-level hit test covers the whole top instead. |
| 88 | + // |
| 89 | + // startDragging() must NOT run on mousedown: it enters the OS drag loop and |
| 90 | + // swallows the browser's dblclick. So begin the drag on the first real move, |
| 91 | + // and detect a double-click from the interval between mousedowns. |
| 92 | + useEffect(() => { |
| 93 | + if (!active) return; |
| 94 | + const TOP_STRIP_PX = 48; |
| 95 | + const DOUBLE_CLICK_MS = 500; |
| 96 | + const DRAG_THRESHOLD_PX = 4; |
| 97 | + const INTERACTIVE = |
| 98 | + "button, a[href], input, textarea, select, label, summary," + |
| 99 | + '[role="button"], [role="tab"], [role="menuitem"], [role="switch"],' + |
| 100 | + '[role="slider"], [contenteditable="true"], [data-no-window-drag]'; |
| 101 | + const draggableAt = (e: MouseEvent) => { |
| 102 | + if (e.button !== 0 || e.clientY > TOP_STRIP_PX) return false; |
| 103 | + const el = e.target as Element | null; |
| 104 | + return !!el && !el.closest(INTERACTIVE); |
| 105 | + }; |
| 106 | + let pending: { x: number; y: number } | null = null; |
| 107 | + let lastDownAt = 0; |
| 108 | + const onMouseDown = (e: MouseEvent) => { |
| 109 | + if (!draggableAt(e)) { |
| 110 | + pending = null; |
| 111 | + return; |
| 112 | + } |
| 113 | + const now = Date.now(); |
| 114 | + if (now - lastDownAt < DOUBLE_CLICK_MS) { |
| 115 | + pending = null; |
| 116 | + lastDownAt = 0; |
| 117 | + void getCurrentWindow().toggleMaximize(); |
| 118 | + return; |
| 119 | + } |
| 120 | + lastDownAt = now; |
| 121 | + pending = { x: e.clientX, y: e.clientY }; |
| 122 | + }; |
| 123 | + const onMouseMove = (e: MouseEvent) => { |
| 124 | + if (!pending) return; |
| 125 | + if ( |
| 126 | + Math.abs(e.clientX - pending.x) > DRAG_THRESHOLD_PX || |
| 127 | + Math.abs(e.clientY - pending.y) > DRAG_THRESHOLD_PX |
| 128 | + ) { |
| 129 | + pending = null; |
| 130 | + lastDownAt = 0; // a drag is not the first half of a double-click |
| 131 | + void getCurrentWindow().startDragging(); |
| 132 | + } |
| 133 | + }; |
| 134 | + const onMouseUp = () => { |
| 135 | + pending = null; |
| 136 | + }; |
| 137 | + document.addEventListener("mousedown", onMouseDown); |
| 138 | + document.addEventListener("mousemove", onMouseMove); |
| 139 | + document.addEventListener("mouseup", onMouseUp); |
| 140 | + return () => { |
| 141 | + document.removeEventListener("mousedown", onMouseDown); |
| 142 | + document.removeEventListener("mousemove", onMouseMove); |
| 143 | + document.removeEventListener("mouseup", onMouseUp); |
| 144 | + }; |
| 145 | + }, [active]); |
| 146 | + |
| 147 | + if (!active) return null; |
| 148 | + |
| 149 | + const appWindow = getCurrentWindow(); |
| 150 | + return ( |
| 151 | + <div className={styles.titleBar}> |
| 152 | + <div className={styles.controls}> |
| 153 | + <button |
| 154 | + type="button" |
| 155 | + className={styles.button} |
| 156 | + onClick={() => void appWindow.minimize()} |
| 157 | + aria-label="Minimize" |
| 158 | + tabIndex={-1} |
| 159 | + > |
| 160 | + <MinimizeIcon fontSize="inherit" /> |
| 161 | + </button> |
| 162 | + <button |
| 163 | + type="button" |
| 164 | + className={styles.button} |
| 165 | + onClick={() => void appWindow.toggleMaximize()} |
| 166 | + aria-label={maximized ? "Restore" : "Maximize"} |
| 167 | + tabIndex={-1} |
| 168 | + > |
| 169 | + {maximized ? ( |
| 170 | + <FilterNoneIcon fontSize="inherit" className={styles.restoreIcon} /> |
| 171 | + ) : ( |
| 172 | + <CropSquareIcon fontSize="inherit" /> |
| 173 | + )} |
| 174 | + </button> |
| 175 | + <button |
| 176 | + type="button" |
| 177 | + className={`${styles.button} ${styles.close}`} |
| 178 | + onClick={() => void appWindow.close()} |
| 179 | + aria-label="Close" |
| 180 | + tabIndex={-1} |
| 181 | + > |
| 182 | + <CloseIcon fontSize="inherit" /> |
| 183 | + </button> |
| 184 | + </div> |
| 185 | + </div> |
| 186 | + ); |
| 187 | +} |
0 commit comments