From e5f8e6c41e2d477e4d4f153986ef61d17a12eeb5 Mon Sep 17 00:00:00 2001 From: imsehun Date: Thu, 3 Sep 2026 15:09:34 +0900 Subject: [PATCH 01/10] feat:arm64 package add --- package.json | 4 ++++ pnpm-lock.yaml | 21 +++++++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e54bc9e0..2dd90534 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,8 @@ "@eslint/js": "10.0.1", "@fontsource-variable/inter": "5.3.0", "@playwright/test": "1.62.1", + "@rolldown/binding-win32-arm64-msvc": "^1.2.6", + "@tailwindcss/oxide-win32-arm64-msvc": "^4.3.3", "@tailwindcss/postcss": "4.3.3", "@tiptap/core": "3.31.0", "@tiptap/extension-color": "3.31.0", @@ -188,6 +190,8 @@ "i18next": "26.4.1", "i18next-browser-languagedetector": "8.2.1", "jsdom": "30.0.1", + "lightningcss": "^1.33.0", + "lightningcss-win32-arm64-msvc": "^1.33.0", "lucide-react": "1.39.0", "postcss": "8.5.26", "prettier": "3.9.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9889856..542b4734 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,12 @@ importers: '@playwright/test': specifier: 1.62.1 version: 1.62.1 + '@rolldown/binding-win32-arm64-msvc': + specifier: ^1.2.6 + version: 1.2.6 + '@tailwindcss/oxide-win32-arm64-msvc': + specifier: ^4.3.3 + version: 4.3.3 '@tailwindcss/postcss': specifier: 4.3.3 version: 4.3.3 @@ -219,6 +225,12 @@ importers: jsdom: specifier: 30.0.1 version: 30.0.1(@noble/hashes@2.4.0) + lightningcss: + specifier: ^1.33.0 + version: 1.33.0 + lightningcss-win32-arm64-msvc: + specifier: ^1.33.0 + version: 1.33.0 lucide-react: specifier: 1.39.0 version: 1.39.0(react@19.2.8) @@ -7057,8 +7069,7 @@ snapshots: '@rolldown/binding-openharmony-arm64@1.2.6': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.6': - optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.6': {} '@rolldown/binding-win32-x64-msvc@1.2.6': optional: true @@ -7137,8 +7148,7 @@ snapshots: '@tailwindcss/oxide-wasm32-wasi@4.3.3': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': - optional: true + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': {} '@tailwindcss/oxide-win32-x64-msvc@4.3.3': optional: true @@ -9469,8 +9479,7 @@ snapshots: lightningcss-win32-arm64-msvc@1.32.0: optional: true - lightningcss-win32-arm64-msvc@1.33.0: - optional: true + lightningcss-win32-arm64-msvc@1.33.0: {} lightningcss-win32-x64-msvc@1.32.0: optional: true From 4c7bde7edfe3b0a1c72374f6a8fb2dd30084fb77 Mon Sep 17 00:00:00 2001 From: Imsehun Date: Thu, 3 Sep 2026 22:39:40 +0900 Subject: [PATCH 02/10] feat:windows trafic BTN header delete --- docs/quality/ipc-channel-allowlist.json | 7 +- docs/quality/ipc-contract-map.json | 122 ++++++++++++------ .../system/window/ipcWindowHandlers.ts | 25 ++++ src/main/manager/window/windowChrome.ts | 16 +++ src/main/manager/window/windowManager.ts | 28 ++++ src/preload/api/windowApi.ts | 14 ++ src/renderer/src/app/App.tsx | 5 + .../src/app/shell/WindowsWindowControls.tsx | 91 +++++++++++++ src/renderer/src/app/shell/index.ts | 1 + src/renderer/src/i18n/locales/en/base/core.ts | 6 + src/renderer/src/i18n/locales/ja/base/core.ts | 6 + src/renderer/src/i18n/locales/ko/base/core.ts | 6 + src/renderer/src/styles/global.tokens.css | 6 + src/shared/api/settings.contract.ts | 4 + src/shared/ipc/channels.ts | 1 + 15 files changed, 297 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/app/shell/WindowsWindowControls.tsx diff --git a/docs/quality/ipc-channel-allowlist.json b/docs/quality/ipc-channel-allowlist.json index 7c95aa7b..a96a70d6 100644 --- a/docs/quality/ipc-channel-allowlist.json +++ b/docs/quality/ipc-channel-allowlist.json @@ -1,7 +1,8 @@ { "channels": [ - { "channelKey": "WINDOW_MINIMIZE", "reason": "네이티브 창 최소화 버튼용으로 정의만 유지" }, - { "channelKey": "WINDOW_UNMAXIMIZE", "reason": "네이티브 창 최대화 해제용으로 정의만 유지" }, - { "channelKey": "WINDOW_TOGGLE_DEV_TOOLS", "reason": "개발용 채널 — 로컬 디버깅에서만 수동 사용" } + { + "channelKey": "WINDOW_TOGGLE_DEV_TOOLS", + "reason": "개발용 채널 — 로컬 디버깅에서만 수동 사용" + } ] } diff --git a/docs/quality/ipc-contract-map.json b/docs/quality/ipc-contract-map.json index fc69354a..07d6b9ad 100644 --- a/docs/quality/ipc-contract-map.json +++ b/docs/quality/ipc-contract-map.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-03T00:45:21.226Z", + "generatedAt": "2026-09-03T13:20:06.002Z", "summary": { - "definedChannels": 224, + "definedChannels": 225, "errors": 0, "warnings": 0 }, @@ -180,7 +180,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 167 + "line": 181 } ], "main_emit": [], @@ -213,7 +213,7 @@ "renderer_listen": [ { "file": "src/preload/api/windowApi.ts", - "line": 27 + "line": 37 } ], "renderer_send": [], @@ -273,7 +273,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 149 + "line": 163 } ], "main_emit": [], @@ -304,7 +304,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 161 + "line": 175 } ], "main_emit": [], @@ -366,7 +366,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 179 + "line": 193 } ], "main_emit": [], @@ -397,7 +397,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 155 + "line": 169 } ], "main_emit": [], @@ -428,7 +428,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 141 + "line": 155 } ], "main_emit": [], @@ -459,7 +459,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 120 + "line": 134 } ], "main_emit": [], @@ -492,7 +492,7 @@ "renderer_listen": [ { "file": "src/preload/api/windowApi.ts", - "line": 40 + "line": 50 } ], "renderer_send": [], @@ -521,7 +521,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 130 + "line": 144 } ], "main_emit": [], @@ -552,7 +552,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 173 + "line": 187 } ], "main_emit": [], @@ -6262,13 +6262,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 48 + "line": 60 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 108 + "line": 122 } ], "main_emit": [], @@ -6293,13 +6293,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 47 + "line": 58 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 188 + "line": 213 } ], "main_emit": [], @@ -6317,14 +6317,19 @@ } }, { - "key": "WINDOW_MINIMIZE", - "channel": "window:minimize", - "allowlisted": true, + "key": "WINDOW_MAXIMIZED_CHANGED", + "channel": "window:maximized-changed", + "allowlisted": false, "usage": { "renderer_invoke": [], "main_handle": [], "main_emit": [], - "renderer_listen": [], + "renderer_listen": [ + { + "file": "src/preload/api/windowApi.ts", + "line": 65 + } + ], "renderer_send": [], "main_listen": [] }, @@ -6332,6 +6337,37 @@ "renderer_invoke": 0, "main_handle": 0, "main_emit": 0, + "renderer_listen": 1, + "renderer_send": 0, + "main_listen": 0 + } + }, + { + "key": "WINDOW_MINIMIZE", + "channel": "window:minimize", + "allowlisted": false, + "usage": { + "renderer_invoke": [ + { + "file": "src/preload/api/windowApi.ts", + "line": 57 + } + ], + "main_handle": [ + { + "file": "src/main/handler/system/window/ipcWindowHandlers.ts", + "line": 202 + } + ], + "main_emit": [], + "renderer_listen": [], + "renderer_send": [], + "main_listen": [] + }, + "usageCounts": { + "renderer_invoke": 1, + "main_handle": 1, + "main_emit": 0, "renderer_listen": 0, "renderer_send": 0, "main_listen": 0 @@ -6345,13 +6381,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 55 + "line": 69 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 254 + "line": 293 } ], "main_emit": [], @@ -6376,13 +6412,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 60 + "line": 74 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 276 + "line": 315 } ], "main_emit": [], @@ -6407,13 +6443,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 51 + "line": 63 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 218 + "line": 257 } ], "main_emit": [], @@ -6438,13 +6474,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 62 + "line": 76 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 285 + "line": 324 } ], "main_emit": [], @@ -6469,13 +6505,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 53 + "line": 67 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 236 + "line": 275 } ], "main_emit": [], @@ -6521,13 +6557,13 @@ "renderer_invoke": [ { "file": "src/preload/api/windowApi.ts", - "line": 49 + "line": 61 } ], "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 202 + "line": 241 } ], "main_emit": [], @@ -6547,18 +6583,28 @@ { "key": "WINDOW_UNMAXIMIZE", "channel": "window:unmaximize", - "allowlisted": true, + "allowlisted": false, "usage": { - "renderer_invoke": [], - "main_handle": [], + "renderer_invoke": [ + { + "file": "src/preload/api/windowApi.ts", + "line": 59 + } + ], + "main_handle": [ + { + "file": "src/main/handler/system/window/ipcWindowHandlers.ts", + "line": 227 + } + ], "main_emit": [], "renderer_listen": [], "renderer_send": [], "main_listen": [] }, "usageCounts": { - "renderer_invoke": 0, - "main_handle": 0, + "renderer_invoke": 1, + "main_handle": 1, "main_emit": 0, "renderer_listen": 0, "renderer_send": 0, diff --git a/src/main/handler/system/window/ipcWindowHandlers.ts b/src/main/handler/system/window/ipcWindowHandlers.ts index 1ecb7f00..d5729171 100644 --- a/src/main/handler/system/window/ipcWindowHandlers.ts +++ b/src/main/handler/system/window/ipcWindowHandlers.ts @@ -198,6 +198,17 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { return getBootstrapStatus(); }, }, + { + channel: IPC_CHANNELS.WINDOW_MINIMIZE, + logTag: "WINDOW_MINIMIZE", + failMessage: "Failed to minimize window", + handler: () => { + const win = windowManager.getMainWindow(); + if (!win) return false; + win.minimize(); + return true; + }, + }, { channel: IPC_CHANNELS.WINDOW_MAXIMIZE, logTag: "WINDOW_MAXIMIZE", @@ -212,6 +223,20 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { return true; }, }, + { + channel: IPC_CHANNELS.WINDOW_UNMAXIMIZE, + logTag: "WINDOW_UNMAXIMIZE", + failMessage: "Failed to unmaximize window", + handler: () => { + const win = windowManager.getMainWindow(); + if (!win) return false; + if (win.isMaximized()) { + win.unmaximize(); + } + win.focus(); + return true; + }, + }, { channel: IPC_CHANNELS.WINDOW_TOGGLE_FULLSCREEN, logTag: "WINDOW_TOGGLE_FULLSCREEN", diff --git a/src/main/manager/window/windowChrome.ts b/src/main/manager/window/windowChrome.ts index 3c4fa4fe..dd2e07bd 100644 --- a/src/main/manager/window/windowChrome.ts +++ b/src/main/manager/window/windowChrome.ts @@ -69,6 +69,22 @@ export const getTitleBarOptions = (): Partial = } } +/** + * Windows 메인 창 전용. 타이틀바 영역(타이틀·네이티브 버튼 포함)을 통째로 제거한다. + * 최소화/최대화/닫기는 렌더러가 macOS traffic lights와 같은 방식으로 앱 안에 직접 + * 그린다(WindowsWindowControls). export/world graph/위저드 등 보조 창은 이 옵션을 + * 쓰지 않고 네이티브 프레임을 유지한다. + */ +export const getWindowsMainTitleBarOptions = (): Partial => { + if (process.platform !== "win32") { + return {} + } + + return { + titleBarStyle: "hidden", + } +} + /** * 커스텀 트래픽 라이트 좌표를 다시 못박는다. * diff --git a/src/main/manager/window/windowManager.ts b/src/main/manager/window/windowManager.ts index 1a43564b..eba41555 100644 --- a/src/main/manager/window/windowManager.ts +++ b/src/main/manager/window/windowManager.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, screen, type BrowserWindowConstructorOptions } from import { join } from "path" import windowStateKeeper from "electron-window-state" import { createLogger } from "../../../shared/logger/index.js" +import { IPC_CHANNELS } from "../../../shared/ipc/channels.js" import { APP_NAME, WINDOW_DEFAULT_HEIGHT, @@ -14,6 +15,7 @@ import { applyWindowMenuBarMode, createSecureWebPreferences, getTitleBarOptions, + getWindowsMainTitleBarOptions, resolveWindowIconPath, shouldShowMenuBar, resolveWindowBackgroundColor, @@ -70,6 +72,29 @@ class WindowManager { session.setSpellCheckerEnabled(this.isSpellcheckEnabled()) } + /** + * 메인 창의 최대화 상태를 렌더러에 알린다. Windows 커스텀 창 버튼이 + * 최대화/복원 아이콘을 전환하는 근원. 버튼 클릭뿐 아니라 Aero Snap·더블클릭 등 + * 시스템 경로로 상태가 바뀌어도 따라가야 해서 이벤트로 흘린다. 최초 로드 시점 + * 동기화는 창 상태 복원으로 이미 최대화된 경우를 위한 것이다. + */ + private attachMaximizedStateEvents(win: BrowserWindow) { + const sendState = () => { + if (win.isDestroyed()) return + win.webContents.send( + IPC_CHANNELS.WINDOW_MAXIMIZED_CHANGED, + win.isMaximized(), + ) + } + + win.on("maximize", sendState) + win.on("unmaximize", sendState) + // NOTE: 테스트용 BrowserWindow mock은 webContents 이벤트 API가 부분 구현이다. + if (typeof win.webContents?.on === "function") { + win.webContents.on("did-finish-load", sendState) + } + } + private createBrowserWindow( options: BrowserWindowConstructorOptions, ): BrowserWindow { @@ -183,11 +208,14 @@ class WindowManager { backgroundColor: resolveWindowBackgroundColor(), ...withWindowIcon(windowIconPath), ...getTitleBarOptions(), + ...getWindowsMainTitleBarOptions(), ...(process.platform !== "darwin" ? { autoHideMenuBar: !shouldShowMenuBar(this.getMenuBarMode()) } : {}), }) + this.attachMaximizedStateEvents(this.mainWindow) + this.applyMenuBarMode(this.mainWindow) windowState.manage(this.mainWindow) if (options.fitWorkArea) { diff --git a/src/preload/api/windowApi.ts b/src/preload/api/windowApi.ts index 2d11223b..dfa4efa3 100644 --- a/src/preload/api/windowApi.ts +++ b/src/preload/api/windowApi.ts @@ -14,6 +14,16 @@ export function createWindowApi({ safeInvoke, safeInvokeCore, }: PreloadApiModuleContext): Pick { + const onChannel = (channel: string, callback: (payload: T) => void) => { + const listener = (_event: IpcRendererEvent, payload: T) => { + callback(payload); + }; + ipcRenderer.on(channel, listener); + return () => { + ipcRenderer.removeListener(channel, listener); + }; + }; + return { lifecycle: { setDirty: (dirty) => { @@ -44,11 +54,15 @@ export function createWindowApi({ }, }, window: { + minimize: () => safeInvoke(IPC_CHANNELS.WINDOW_MINIMIZE), maximize: () => safeInvoke(IPC_CHANNELS.WINDOW_MAXIMIZE), + unmaximize: () => safeInvoke(IPC_CHANNELS.WINDOW_UNMAXIMIZE), close: () => safeInvoke(IPC_CHANNELS.WINDOW_CLOSE), toggleFullscreen: () => safeInvoke(IPC_CHANNELS.WINDOW_TOGGLE_FULLSCREEN), setFullscreen: (flag) => safeInvoke(IPC_CHANNELS.WINDOW_SET_FULLSCREEN, flag), + onMaximizeChanged: (callback) => + onChannel(IPC_CHANNELS.WINDOW_MAXIMIZED_CHANGED, callback), setTrafficLightVisibility: (visible) => safeInvoke(IPC_CHANNELS.WINDOW_SET_TRAFFIC_LIGHT_VISIBILITY, visible), openExport: (chapterId) => diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 94ae91b5..e8e85c5f 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -36,6 +36,7 @@ import { useThemeAttributes, useUiModeIntegrityDevCheck, useWindowMode, + WindowsWindowControls, } from "./shell"; const ExportWindow = lazy( @@ -473,6 +474,8 @@ export default function App() { if (!bootstrapStatus.isReady) { return ( <> + {/* NOTE: 메인 창(Windows frameless)은 어느 게이트 화면에서도 창 버튼이 필요하다. */} + + + diff --git a/src/renderer/src/app/shell/WindowsWindowControls.tsx b/src/renderer/src/app/shell/WindowsWindowControls.tsx new file mode 100644 index 00000000..471d1318 --- /dev/null +++ b/src/renderer/src/app/shell/WindowsWindowControls.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +import { Copy, Minus, Square, X } from "lucide-react"; +import { api } from "@shared/api"; + +const IS_WINDOWS = navigator.userAgent.toLowerCase().includes("win"); + +const NO_DRAG = { WebkitAppRegion: "no-drag" } as CSSProperties; +const DRAG = { WebkitAppRegion: "drag" } as CSSProperties; + +const CONTROL_BUTTON_CLASS = + "flex w-[46px] items-center justify-center text-muted transition-colors hover:bg-surface-hover hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring cursor-default"; + +/** + * Windows 메인 창의 인앱 창 버튼(최소화/최대화/닫기). 메인 창이 + * `titleBarStyle: "hidden"`으로 생성돼 네이티브 타이틀바가 없을 때 macOS traffic + * lights와 같은 역할을 한다. 보조 창(export/world graph/위저드)은 네이티브 + * 프레임을 유지하므로 이 컴포넌트를 쓰지 않는다. + * + * 좌측 투명 밴드는 frameless 창의 이동 확보용 드래그 영역이다. 드래그 영역은 + * pointer 이벤트를 무시하므로(EditorLayout 센티넬 NOTE 참고) 버튼이 놓일 우상단 + * 코너로 폭을 최소화한다. + */ +export function WindowsWindowControls() { + const { t } = useTranslation(); + const [isMaximized, setIsMaximized] = useState(false); + + useEffect(() => { + if (!IS_WINDOWS) return; + // NOTE: 부분 api mock(dom 테스트)에서도 깨지지 않게 옵셔널로 구독한다(App.tsx 패턴). + if (typeof api.window?.onMaximizeChanged !== "function") return; + return api.window.onMaximizeChanged(setIsMaximized); + }, []); + + if (!IS_WINDOWS) return null; + + const handleToggleMaximize = () => { + if (isMaximized) { + void api.window.unmaximize(); + return; + } + void api.window.maximize(); + }; + + return ( +
+ + ); +} diff --git a/src/renderer/src/app/shell/index.ts b/src/renderer/src/app/shell/index.ts index 634759b5..d64e1d5c 100644 --- a/src/renderer/src/app/shell/index.ts +++ b/src/renderer/src/app/shell/index.ts @@ -1,6 +1,7 @@ export { BootstrapGate } from "./BootstrapGate"; export { parseBootstrapStatus } from "./bootstrapStatus"; export { QuitOverlay } from "./QuitOverlay"; +export { WindowsWindowControls } from "./WindowsWindowControls"; export { useThemeAttributes } from "./useThemeAttributes"; export { useUiModeIntegrityDevCheck } from "./useUiModeIntegrityDevCheck"; export { useWindowMode } from "./windowMode"; diff --git a/src/renderer/src/i18n/locales/en/base/core.ts b/src/renderer/src/i18n/locales/en/base/core.ts index e197239e..2e70e5a0 100644 --- a/src/renderer/src/i18n/locales/en/base/core.ts +++ b/src/renderer/src/i18n/locales/en/base/core.ts @@ -7,6 +7,12 @@ export const enBaseCore = { undo: "Undo", clear: "Clear", exit: "Exit", + windowControls: { + minimize: "Minimize", + maximize: "Maximize", + restore: "Restore", + close: "Close", + }, menu: { file: "File", edit: "Edit", diff --git a/src/renderer/src/i18n/locales/ja/base/core.ts b/src/renderer/src/i18n/locales/ja/base/core.ts index 9cf1fa2d..10db7a6e 100644 --- a/src/renderer/src/i18n/locales/ja/base/core.ts +++ b/src/renderer/src/i18n/locales/ja/base/core.ts @@ -7,6 +7,12 @@ export const jaBaseCore = { undo: "元に戻す", clear: "クリア", exit: "終了", + windowControls: { + minimize: "最小化", + maximize: "最大化", + restore: "元に戻す", + close: "閉じる", + }, menu: { file: "ファイル", edit: "編集", diff --git a/src/renderer/src/i18n/locales/ko/base/core.ts b/src/renderer/src/i18n/locales/ko/base/core.ts index c58bac82..60db4c99 100644 --- a/src/renderer/src/i18n/locales/ko/base/core.ts +++ b/src/renderer/src/i18n/locales/ko/base/core.ts @@ -7,6 +7,12 @@ export const koBaseCore = { undo: "실행 취소", clear: "지우기", exit: "나가기", + windowControls: { + minimize: "최소화", + maximize: "최대화", + restore: "이전 크기로 복원", + close: "닫기", + }, menu: { file: "파일", edit: "편집", diff --git a/src/renderer/src/styles/global.tokens.css b/src/renderer/src/styles/global.tokens.css index 3c829060..3b021750 100644 --- a/src/renderer/src/styles/global.tokens.css +++ b/src/renderer/src/styles/global.tokens.css @@ -223,6 +223,9 @@ --z-index-toolbar: 120; --z-index-toast: 150; --z-index-modal: 1000; + /* NOTE: Windows 인앱 창 버튼(WindowsWindowControls)은 창 크로미다. 모달이 떠도 + 네이티브 타이틀바처럼 클릭 가능해야 하므로 modal(1000) 위, 종료 오버레이 아래. */ + --z-index-window-controls: 1100; /* NOTE: 종료 오버레이(QuitOverlay)는 앱을 닫는 절차 동안 모든 모달 위에 떠야 한다. modal(1000)보다 위여야 하므로 별도 계층으로 둔다. */ --z-index-quit: 9999; @@ -243,6 +246,9 @@ @utility z-modal { z-index: var(--z-index-modal); } +@utility z-window-controls { + z-index: var(--z-index-window-controls); +} @utility z-quit { z-index: var(--z-index-quit); } diff --git a/src/shared/api/settings.contract.ts b/src/shared/api/settings.contract.ts index cd634365..7b2826d2 100644 --- a/src/shared/api/settings.contract.ts +++ b/src/shared/api/settings.contract.ts @@ -180,10 +180,14 @@ export type SettingsRendererApi = { ) => Promise>; }; window: { + minimize: () => Promise>; maximize: () => Promise>; + unmaximize: () => Promise>; close: () => Promise>; toggleFullscreen: () => Promise>; setFullscreen: (flag: boolean) => Promise>; + /** 메인 창 최대화 상태 변경 이벤트. Windows 인앱 창 버튼의 아이콘 전환에 쓴다. */ + onMaximizeChanged: (callback: (maximized: boolean) => void) => () => void; /** macOS 전용. 다른 플랫폼에서는 no-op으로 응답한다. */ setTrafficLightVisibility: ( visible: boolean, diff --git a/src/shared/ipc/channels.ts b/src/shared/ipc/channels.ts index bd013b8e..e2494699 100644 --- a/src/shared/ipc/channels.ts +++ b/src/shared/ipc/channels.ts @@ -191,6 +191,7 @@ export const IPC_CHANNELS = { WINDOW_OPEN_EXPORT: "window:open-export", WINDOW_OPEN_WORLD_GRAPH: "window:open-world-graph", WINDOW_SET_STARTUP_WIZARD_SIZE: "window:set-startup-wizard-size", + WINDOW_MAXIMIZED_CHANGED: "window:maximized-changed", APP_GET_VERSION: "app:get-version", APP_CHECK_UPDATE: "app:check-update", From cf3cd302f467ac4ab0e94e2a31218bb04f9c5f11 Mon Sep 17 00:00:00 2001 From: Imsehun Date: Thu, 3 Sep 2026 22:53:32 +0900 Subject: [PATCH 03/10] feat:windows trafic BTN stepup window header delete --- docs/quality/ipc-contract-map.json | 40 +++++++++---------- .../system/window/ipcWindowHandlers.ts | 20 ++++++++-- src/main/manager/window/windowChrome.ts | 33 +++++++++++++-- src/main/manager/window/windowManager.ts | 31 ++------------ .../manager/window/windowStartupWizard.ts | 6 +++ src/renderer/src/app/App.tsx | 2 + 6 files changed, 77 insertions(+), 55 deletions(-) diff --git a/docs/quality/ipc-contract-map.json b/docs/quality/ipc-contract-map.json index 07d6b9ad..97f936ca 100644 --- a/docs/quality/ipc-contract-map.json +++ b/docs/quality/ipc-contract-map.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-03T13:20:06.002Z", + "generatedAt": "2026-09-03T13:47:07.602Z", "summary": { "definedChannels": 225, "errors": 0, @@ -180,7 +180,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 181 + "line": 193 } ], "main_emit": [], @@ -273,7 +273,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 163 + "line": 175 } ], "main_emit": [], @@ -304,7 +304,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 175 + "line": 187 } ], "main_emit": [], @@ -366,7 +366,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 193 + "line": 205 } ], "main_emit": [], @@ -397,7 +397,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 169 + "line": 181 } ], "main_emit": [], @@ -428,7 +428,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 155 + "line": 167 } ], "main_emit": [], @@ -459,7 +459,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 134 + "line": 146 } ], "main_emit": [], @@ -521,7 +521,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 144 + "line": 156 } ], "main_emit": [], @@ -552,7 +552,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 187 + "line": 199 } ], "main_emit": [], @@ -6268,7 +6268,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 122 + "line": 134 } ], "main_emit": [], @@ -6299,7 +6299,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 213 + "line": 225 } ], "main_emit": [], @@ -6356,7 +6356,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 202 + "line": 214 } ], "main_emit": [], @@ -6387,7 +6387,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 293 + "line": 305 } ], "main_emit": [], @@ -6418,7 +6418,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 315 + "line": 327 } ], "main_emit": [], @@ -6449,7 +6449,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 257 + "line": 269 } ], "main_emit": [], @@ -6480,7 +6480,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 324 + "line": 336 } ], "main_emit": [], @@ -6511,7 +6511,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 275 + "line": 287 } ], "main_emit": [], @@ -6563,7 +6563,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 241 + "line": 253 } ], "main_emit": [], @@ -6594,7 +6594,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 227 + "line": 239 } ], "main_emit": [], diff --git a/src/main/handler/system/window/ipcWindowHandlers.ts b/src/main/handler/system/window/ipcWindowHandlers.ts index d5729171..3f591941 100644 --- a/src/main/handler/system/window/ipcWindowHandlers.ts +++ b/src/main/handler/system/window/ipcWindowHandlers.ts @@ -31,6 +31,18 @@ const WIZARD_RESIZE_TICK_MS = 16; let wizardResizeTimer: ReturnType | null = null; +// NOTE: 창 제어 채널(minimize/maximize/unmaximize/close)은 "지금 사용자가 보고 있는 +// 창"을 대상으로 한다. 시작 위저드는 메인 창 이전에 단독으로 뜨고 완료되면 닫힌 뒤 +// 메인 창이 생성되므로(동시 공존 없음 — appReady의 wizard-complete 플로우), 위저드 +// 창이 살아 있으면 그것이 곧 호출 주체다. +const resolveWindowControlTarget = (): BrowserWindow | null => { + const wizard = windowManager.getStartupWizardWindow(); + if (wizard && !wizard.isDestroyed()) { + return wizard; + } + return windowManager.getMainWindow(); +}; + // 디스플레이 작업 영역(workArea) 내에서 대상 영역을 계산한다. // 1. 음수 크기(-1, -1 등): A 인트로/모델 단계의 초기 콤팩트 bounds로 복원 // 2. 최대 크기(4000 이상 또는 workArea 이상): 여백 없이 workArea 100% 채움 @@ -124,7 +136,7 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { failMessage: "Failed to close window", handler: () => { logger.info("WINDOW_CLOSE requested from renderer"); - const win = windowManager.getMainWindow(); + const win = resolveWindowControlTarget(); if (!win) return false; win.close(); return true; @@ -203,7 +215,7 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { logTag: "WINDOW_MINIMIZE", failMessage: "Failed to minimize window", handler: () => { - const win = windowManager.getMainWindow(); + const win = resolveWindowControlTarget(); if (!win) return false; win.minimize(); return true; @@ -214,7 +226,7 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { logTag: "WINDOW_MAXIMIZE", failMessage: "Failed to maximize window", handler: () => { - const win = windowManager.getMainWindow(); + const win = resolveWindowControlTarget(); if (!win) return false; if (!win.isMaximized()) { win.maximize(); @@ -228,7 +240,7 @@ export function registerWindowIPCHandlers(logger: LoggerLike): void { logTag: "WINDOW_UNMAXIMIZE", failMessage: "Failed to unmaximize window", handler: () => { - const win = windowManager.getMainWindow(); + const win = resolveWindowControlTarget(); if (!win) return false; if (win.isMaximized()) { win.unmaximize(); diff --git a/src/main/manager/window/windowChrome.ts b/src/main/manager/window/windowChrome.ts index dd2e07bd..b9f63ed2 100644 --- a/src/main/manager/window/windowChrome.ts +++ b/src/main/manager/window/windowChrome.ts @@ -2,6 +2,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions } from "electron" import { app } from "electron" import { existsSync } from "fs" import { join } from "path" +import { IPC_CHANNELS } from "../../../shared/ipc/channels.js" import { WINDOW_TRAFFIC_LIGHT_X, WINDOW_TRAFFIC_LIGHT_Y, @@ -70,12 +71,13 @@ export const getTitleBarOptions = (): Partial = } /** - * Windows 메인 창 전용. 타이틀바 영역(타이틀·네이티브 버튼 포함)을 통째로 제거한다. + * Windows에서 네이티브 타이틀바 영역(타이틀·버튼 포함)을 통째로 제거한다. * 최소화/최대화/닫기는 렌더러가 macOS traffic lights와 같은 방식으로 앱 안에 직접 - * 그린다(WindowsWindowControls). export/world graph/위저드 등 보조 창은 이 옵션을 - * 쓰지 않고 네이티브 프레임을 유지한다. + * 그린다(WindowsWindowControls). 메인 창과 시작 위저드가 이 옵션을 쓴다 — 두 창의 + * 렌더러 모두 인앱 창 버튼을 렌더한다. export/world graph 등 보조 창은 네이티브 + * 프레임을 유지한다. */ -export const getWindowsMainTitleBarOptions = (): Partial => { +export const getWindowsFramelessTitleBarOptions = (): Partial => { if (process.platform !== "win32") { return {} } @@ -85,6 +87,29 @@ export const getWindowsMainTitleBarOptions = (): Partial { + const sendState = () => { + if (win.isDestroyed()) return + win.webContents.send( + IPC_CHANNELS.WINDOW_MAXIMIZED_CHANGED, + win.isMaximized(), + ) + } + + win.on("maximize", sendState) + win.on("unmaximize", sendState) + // NOTE: 테스트용 BrowserWindow mock은 webContents 이벤트 API가 부분 구현이다. + if (typeof win.webContents?.on === "function") { + win.webContents.on("did-finish-load", sendState) + } +} + /** * 커스텀 트래픽 라이트 좌표를 다시 못박는다. * diff --git a/src/main/manager/window/windowManager.ts b/src/main/manager/window/windowManager.ts index eba41555..d29bcbf0 100644 --- a/src/main/manager/window/windowManager.ts +++ b/src/main/manager/window/windowManager.ts @@ -2,7 +2,6 @@ import { app, BrowserWindow, screen, type BrowserWindowConstructorOptions } from import { join } from "path" import windowStateKeeper from "electron-window-state" import { createLogger } from "../../../shared/logger/index.js" -import { IPC_CHANNELS } from "../../../shared/ipc/channels.js" import { APP_NAME, WINDOW_DEFAULT_HEIGHT, @@ -13,9 +12,10 @@ import { import { settingsManager } from "../settings/index.js" import { applyWindowMenuBarMode, + attachMaximizedStateEvents, createSecureWebPreferences, getTitleBarOptions, - getWindowsMainTitleBarOptions, + getWindowsFramelessTitleBarOptions, resolveWindowIconPath, shouldShowMenuBar, resolveWindowBackgroundColor, @@ -72,29 +72,6 @@ class WindowManager { session.setSpellCheckerEnabled(this.isSpellcheckEnabled()) } - /** - * 메인 창의 최대화 상태를 렌더러에 알린다. Windows 커스텀 창 버튼이 - * 최대화/복원 아이콘을 전환하는 근원. 버튼 클릭뿐 아니라 Aero Snap·더블클릭 등 - * 시스템 경로로 상태가 바뀌어도 따라가야 해서 이벤트로 흘린다. 최초 로드 시점 - * 동기화는 창 상태 복원으로 이미 최대화된 경우를 위한 것이다. - */ - private attachMaximizedStateEvents(win: BrowserWindow) { - const sendState = () => { - if (win.isDestroyed()) return - win.webContents.send( - IPC_CHANNELS.WINDOW_MAXIMIZED_CHANGED, - win.isMaximized(), - ) - } - - win.on("maximize", sendState) - win.on("unmaximize", sendState) - // NOTE: 테스트용 BrowserWindow mock은 webContents 이벤트 API가 부분 구현이다. - if (typeof win.webContents?.on === "function") { - win.webContents.on("did-finish-load", sendState) - } - } - private createBrowserWindow( options: BrowserWindowConstructorOptions, ): BrowserWindow { @@ -208,13 +185,13 @@ class WindowManager { backgroundColor: resolveWindowBackgroundColor(), ...withWindowIcon(windowIconPath), ...getTitleBarOptions(), - ...getWindowsMainTitleBarOptions(), + ...getWindowsFramelessTitleBarOptions(), ...(process.platform !== "darwin" ? { autoHideMenuBar: !shouldShowMenuBar(this.getMenuBarMode()) } : {}), }) - this.attachMaximizedStateEvents(this.mainWindow) + attachMaximizedStateEvents(this.mainWindow) this.applyMenuBarMode(this.mainWindow) windowState.manage(this.mainWindow) diff --git a/src/main/manager/window/windowStartupWizard.ts b/src/main/manager/window/windowStartupWizard.ts index d173b067..3abb363a 100644 --- a/src/main/manager/window/windowStartupWizard.ts +++ b/src/main/manager/window/windowStartupWizard.ts @@ -2,7 +2,9 @@ import { screen, type BrowserWindow, type BrowserWindowConstructorOptions } from import { APP_NAME } from "../../../shared/constants/index.js" import { applyWindowMenuBarMode, + attachMaximizedStateEvents, getTitleBarOptions, + getWindowsFramelessTitleBarOptions, resolveWindowIconPath, withWindowIcon, } from "./windowChrome.js" @@ -158,9 +160,13 @@ export const createStartupWizardBrowserWindow = ( backgroundColor: "#212123", ...withWindowIcon(resolveWindowIconPath()), ...getTitleBarOptions(), + ...getWindowsFramelessTitleBarOptions(), ...(process.platform !== "darwin" ? { autoHideMenuBar: true } : {}), }) + // Windows frameless 위저드도 인앱 창 버튼(WindowsWindowControls)을 렌더하므로 + // 최대화 상태 이벤트를 동일하게 공급한다. + attachMaximizedStateEvents(window) applyWindowMenuBarMode(window, input.getMenuBarMode()) void loadRendererRoute({ label: "startup wizard", diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index e8e85c5f..d8f3e06f 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -463,6 +463,8 @@ export default function App() { if (windowMode === "startup-wizard") { return ( <> + {/* Windows frameless 위저드도 메인 창과 같은 인앱 창 버튼을 쓴다. */} + From 5491eb3f5a38f7b70b7a0b8e4bb5111a0dae8ecd Mon Sep 17 00:00:00 2001 From: Imsehun Date: Thu, 3 Sep 2026 23:49:15 +0900 Subject: [PATCH 04/10] feat:eidtor layout trafic BTN hide --- docs/quality/ipc-contract-map.json | 40 +++++------ .../system/window/ipcWindowHandlers.ts | 24 +++++-- .../manager/window/windowSecondaryWindows.ts | 6 ++ src/renderer/src/app/App.tsx | 2 + .../src/app/shell/WindowsWindowControls.tsx | 68 +++++++++++++------ .../app/shell/windowsWindowControlsStore.ts | 38 +++++++++++ .../shell/document/CanvasDocumentChrome.tsx | 27 +++++++- .../export/components/ExportSidebar.tsx | 10 ++- .../components/layout/EditorLayout.tsx | 33 +++++++++ .../layout/GoogleDocsRightPanel.tsx | 15 +++- .../components/layout/MainLayout.tsx | 7 +- 11 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/app/shell/windowsWindowControlsStore.ts diff --git a/docs/quality/ipc-contract-map.json b/docs/quality/ipc-contract-map.json index 97f936ca..a5e3ea13 100644 --- a/docs/quality/ipc-contract-map.json +++ b/docs/quality/ipc-contract-map.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-09-03T13:47:07.602Z", + "generatedAt": "2026-09-03T14:29:50.926Z", "summary": { "definedChannels": 225, "errors": 0, @@ -180,7 +180,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 193 + "line": 205 } ], "main_emit": [], @@ -273,7 +273,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 175 + "line": 187 } ], "main_emit": [], @@ -304,7 +304,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 187 + "line": 199 } ], "main_emit": [], @@ -366,7 +366,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 205 + "line": 217 } ], "main_emit": [], @@ -397,7 +397,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 181 + "line": 193 } ], "main_emit": [], @@ -428,7 +428,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 167 + "line": 179 } ], "main_emit": [], @@ -459,7 +459,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 146 + "line": 158 } ], "main_emit": [], @@ -521,7 +521,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 156 + "line": 168 } ], "main_emit": [], @@ -552,7 +552,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 199 + "line": 211 } ], "main_emit": [], @@ -6268,7 +6268,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 134 + "line": 146 } ], "main_emit": [], @@ -6299,7 +6299,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 225 + "line": 237 } ], "main_emit": [], @@ -6356,7 +6356,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 214 + "line": 226 } ], "main_emit": [], @@ -6387,7 +6387,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 305 + "line": 317 } ], "main_emit": [], @@ -6418,7 +6418,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 327 + "line": 339 } ], "main_emit": [], @@ -6449,7 +6449,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 269 + "line": 281 } ], "main_emit": [], @@ -6480,7 +6480,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 336 + "line": 348 } ], "main_emit": [], @@ -6511,7 +6511,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 287 + "line": 299 } ], "main_emit": [], @@ -6563,7 +6563,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 253 + "line": 265 } ], "main_emit": [], @@ -6594,7 +6594,7 @@ "main_handle": [ { "file": "src/main/handler/system/window/ipcWindowHandlers.ts", - "line": 239 + "line": 251 } ], "main_emit": [], diff --git a/src/main/handler/system/window/ipcWindowHandlers.ts b/src/main/handler/system/window/ipcWindowHandlers.ts index 3f591941..18dc18ab 100644 --- a/src/main/handler/system/window/ipcWindowHandlers.ts +++ b/src/main/handler/system/window/ipcWindowHandlers.ts @@ -1,4 +1,8 @@ -import { app, screen } from "electron"; +import { + app, + screen, + BrowserWindow, +} from "electron"; import { windowManager } from "../../../app/windows/index.js"; import { applyTrafficLightPosition } from "../../../manager/window/windowChrome.js"; import { IPC_CHANNELS } from "../../../../shared/ipc/channels.js"; @@ -17,7 +21,6 @@ import { windowSetStartupWizardSizeArgsSchema, windowSetTrafficLightVisibilityArgsSchema, } from "../../../../shared/schemas/index.js"; -import type { BrowserWindow } from "electron"; import { calculateStartupWizardExpandedBounds, calculateStartupWizardInitialBounds, @@ -31,11 +34,20 @@ const WIZARD_RESIZE_TICK_MS = 16; let wizardResizeTimer: ReturnType | null = null; -// NOTE: 창 제어 채널(minimize/maximize/unmaximize/close)은 "지금 사용자가 보고 있는 -// 창"을 대상으로 한다. 시작 위저드는 메인 창 이전에 단독으로 뜨고 완료되면 닫힌 뒤 -// 메인 창이 생성되므로(동시 공존 없음 — appReady의 wizard-complete 플로우), 위저드 -// 창이 살아 있으면 그것이 곧 호출 주체다. +// NOTE: 창 제어 채널(minimize/maximize/unmaximize/close)은 "호출한 렌더러가 속한 +// 창"을 대상으로 해야 한다(메인·내보내기 창 동시 실행 대응). 핸들러 규약상 event를 +// 받지 못하므로 포커스된 창으로 호출 주체를 판정한다 — 버튼/단축키는 클릭·포커스된 +// 창의 렌더러에서 발화하므로 성립한다. 포커스를 잃은 비정상 경로의 폴백으로, 시작 +// 위저드는 메인 창 이전에 단독으로 뜨고 완료되면 닫힌 뒤 메인 창이 생성되므로(동시 +// 공존 없음 — appReady의 wizard-complete 플로우) 위저드가 살아 있으면 그것이 곧 +// 호출 주체다. const resolveWindowControlTarget = (): BrowserWindow | null => { + if (typeof BrowserWindow.getFocusedWindow === "function") { + const focused = BrowserWindow.getFocusedWindow(); + if (focused && !focused.isDestroyed()) { + return focused; + } + } const wizard = windowManager.getStartupWizardWindow(); if (wizard && !wizard.isDestroyed()) { return wizard; diff --git a/src/main/manager/window/windowSecondaryWindows.ts b/src/main/manager/window/windowSecondaryWindows.ts index d715f24c..b43c08ad 100644 --- a/src/main/manager/window/windowSecondaryWindows.ts +++ b/src/main/manager/window/windowSecondaryWindows.ts @@ -4,7 +4,9 @@ import type { BrowserWindow, BrowserWindowConstructorOptions } from "electron" import { applyWindowMenuBarMode, + attachMaximizedStateEvents, getTitleBarOptions, + getWindowsFramelessTitleBarOptions, resolveWindowIconPath, shouldShowMenuBar, resolveWindowBackgroundColor, @@ -83,9 +85,13 @@ export const createExportBrowserWindow = ( backgroundColor: resolveWindowBackgroundColor(), ...withWindowIcon(resolveWindowIconPath()), ...getTitleBarOptions(), + // NOTE: 내보내기 창의 렌더러도 인앱 창 버튼(WindowsWindowControls)을 그린다. + ...getWindowsFramelessTitleBarOptions(), ...secondaryWindowMenuOptions(menuBarMode), }) + // Windows frameless 창 버튼의 최대화/복원 전환용 상태 이벤트를 공급한다. + attachMaximizedStateEvents(window) applyWindowMenuBarMode(window, menuBarMode) const route = { hash: "export", search: `?chapterId=${chapterId}` } void loadSecondaryWindowRoute({ diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index d8f3e06f..7cef73ef 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -411,6 +411,8 @@ export default function App() { if (windowMode === "export") { return ( <> + {/* Windows frameless 내보내기 창도 메인 창과 같은 인앱 창 버튼을 쓴다. */} + diff --git a/src/renderer/src/app/shell/WindowsWindowControls.tsx b/src/renderer/src/app/shell/WindowsWindowControls.tsx index 471d1318..015925fa 100644 --- a/src/renderer/src/app/shell/WindowsWindowControls.tsx +++ b/src/renderer/src/app/shell/WindowsWindowControls.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { Copy, Minus, Square, X } from "lucide-react"; import { api } from "@shared/api"; +import { useWindowsWindowControlsStore } from "./windowsWindowControlsStore"; const IS_WINDOWS = navigator.userAgent.toLowerCase().includes("win"); @@ -9,21 +10,28 @@ const NO_DRAG = { WebkitAppRegion: "no-drag" } as CSSProperties; const DRAG = { WebkitAppRegion: "drag" } as CSSProperties; const CONTROL_BUTTON_CLASS = - "flex w-[46px] items-center justify-center text-muted transition-colors hover:bg-surface-hover hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring cursor-default"; + "flex w-11 items-center justify-center text-muted transition-colors hover:bg-surface-hover hover:text-fg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring cursor-default"; /** - * Windows 메인 창의 인앱 창 버튼(최소화/최대화/닫기). 메인 창이 - * `titleBarStyle: "hidden"`으로 생성돼 네이티브 타이틀바가 없을 때 macOS traffic - * lights와 같은 역할을 한다. 보조 창(export/world graph/위저드)은 네이티브 - * 프레임을 유지하므로 이 컴포넌트를 쓰지 않는다. + * Windows 인앱 창 버튼(최소화/최대화/닫기). 네이티브 타이틀바가 제거된 창(메인·시작 + * 위저드·내보내기, 모두 `titleBarStyle: "hidden"`)에서 macOS traffic lights와 같은 + * 역할을 한다. + * + * 기본 인스턴스는 App 셸에 고정으로 마운트되며, 레이아웃은 + * useWindowsWindowControlsStore로 표시/위치를 제어한다(EditorLayout은 툴바 hover + * 때만 노출, Canvas는 헤더 높이 정렬 등). `embedded` 변형은 부모 영역 안에 직접 + * 넣을 때 쓴다 — 고정 위치·드래그 밴드·z-index 없이 버튼 묶음만 렌더한다. * * 좌측 투명 밴드는 frameless 창의 이동 확보용 드래그 영역이다. 드래그 영역은 * pointer 이벤트를 무시하므로(EditorLayout 센티넬 NOTE 참고) 버튼이 놓일 우상단 * 코너로 폭을 최소화한다. */ -export function WindowsWindowControls() { +export function WindowsWindowControls({ embedded = false }: { embedded?: boolean }) { const { t } = useTranslation(); const [isMaximized, setIsMaximized] = useState(false); + const visible = useWindowsWindowControlsStore((state) => state.visible); + const topInset = useWindowsWindowControlsStore((state) => state.topInset); + const rightInset = useWindowsWindowControlsStore((state) => state.rightInset); useEffect(() => { if (!IS_WINDOWS) return; @@ -33,28 +41,23 @@ export function WindowsWindowControls() { }, []); if (!IS_WINDOWS) return null; + if (!embedded && !visible) return null; const handleToggleMaximize = () => { if (isMaximized) { - void api.window.unmaximize(); + void api.window?.unmaximize?.(); return; } - void api.window.maximize(); + void api.window?.maximize?.(); }; - return ( -
-