diff --git a/components/Editor.tsx b/components/Editor.tsx index 8c4ceb9..81b60cd 100644 --- a/components/Editor.tsx +++ b/components/Editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels"; import { useEditorStore } from "@/lib/store"; import { getCutRanges, isWordCutOut } from "@/lib/edits"; @@ -23,7 +23,6 @@ import MediaPreview from "./MediaPreview"; import Timeline from "./Timeline"; import ExportDialog from "./ExportDialog"; import { Download, Redo2, Undo2 } from "lucide-react"; -import LogoLoader from "./LogoLoader"; import SettingsMenu from "./SettingsMenu"; import ModelSelector, { LanguageSection, @@ -34,11 +33,6 @@ import ImportTranscriptOption from "./ImportTranscriptOption"; import { MODEL_ORDER } from "@/lib/models"; import { isTypingTarget } from "@/lib/keyboard"; -/** How long the desktop mode-change overlay stays up. Matches the macOS - * `setBounds(..., animate)` duration plus a small buffer so the layout - * underneath isn't revealed mid-resize. */ -const WINDOW_MODE_OVERLAY_MS = 380; - /** Transcript and preview split, resizable in both orientations. Wide screens * put the transcript first (left of the preview); stacked screens lead with * the preview on top. Each orientation remembers its own sizes. */ @@ -140,9 +134,6 @@ export default function Editor() { const redo = useEditorStore((s) => s.redo); const setExportOpen = useEditorStore((s) => s.setExportOpen); - const [modeTransitioning, setModeTransitioning] = useState(false); - const wasIdle = useRef(status === "idle"); - // File › Open Project… reaches the same picker the upload screen uses, from // anywhere in the app. const menuInputRef = useRef(null); @@ -247,18 +238,11 @@ export default function Editor() { })(); }, [videoFile, skipTranscription, transcribe]); - // The desktop shell opens as a small upload window and grows once the - // three-pane editor takes over (and shrinks back on "start over"). - // Cover the swap with a brief overlay so the layout reflow isn't visible - // while the window animates between sizes. + // The main process uses this mode only to preserve the native close behavior + // (close project first, then close the upload window). It no longer resizes + // the window; user-chosen bounds are persisted by Electron instead. useEffect(() => { - const idle = status === "idle"; - window.rescriptDesktop?.setWindowMode(idle ? "compact" : "expanded"); - if (!isElectron || wasIdle.current === idle) return; - wasIdle.current = idle; - setModeTransitioning(true); - const timer = window.setTimeout(() => setModeTransitioning(false), WINDOW_MODE_OVERLAY_MS); - return () => window.clearTimeout(timer); + window.rescriptDesktop?.setWindowMode(status === "idle" ? "compact" : "expanded"); }, [status]); // Global shortcuts: space = play/pause, ⌘Z / ⇧⌘Z = undo / redo, S = split, @@ -398,15 +382,6 @@ export default function Editor() { )} - {modeTransitioning && ( -
- -
- )} {isElectron && ( // Present in the layout tree (not display:none) so the native menu's // click() reliably opens the picker. diff --git a/electron/main.ts b/electron/main.ts index 3440e55..b42c5dc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -10,7 +10,7 @@ import { } from "electron"; import { join, normalize, extname } from "node:path"; import { pathToFileURL } from "node:url"; -import { existsSync, statSync } from "node:fs"; +import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { initMainSentry, setMainTelemetryEnabled } from "./sentry"; import { initAutoUpdater } from "./updater"; import { @@ -26,13 +26,19 @@ const isMac = process.platform === "darwin"; type WindowMode = "compact" | "expanded"; -/** The shell has two resting sizes: a small window for the upload screen, and a - * roomy one once the editor (transcript + preview + timeline) takes over. */ -const WINDOW_SIZES: Record = { - compact: { width: 560, height: 400 }, - expanded: { width: 1080, height: 740 }, +/** Start roomy enough for the three-pane editor; subsequent launches restore + * the user's own normal bounds instead of forcing upload/editor sizes. */ +const DEFAULT_WINDOW_SIZE = { width: 1280, height: 820 }; +const MIN_SIZE = { width: 720, height: 480 }; +const WINDOW_STATE_FILE = "window-state.json"; + +type StoredWindowState = { + x: number; + y: number; + width: number; + height: number; + maximized: boolean; }; -const MIN_SIZE = { width: 560, height: 400 }; /** Height of the in-page drag strip (`h-12`), used to centre the traffic lights. */ const TITLE_BAR_HEIGHT = 48; @@ -179,39 +185,72 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -/** Resize a window to the given mode's resting size, keeping it centred on - * wherever the user left it rather than snapping to a corner. */ -function applyWindowMode(win: BrowserWindow, mode: WindowMode): void { - if (windowModes.get(win) === mode) return; - windowModes.set(win, mode); - // A maximized or full-screen window is already the size the user asked for. - if (win.isFullScreen() || win.isMaximized()) return; - - const current = win.getBounds(); - const { workArea } = screen.getDisplayMatching(current); - const width = Math.min(WINDOW_SIZES[mode].width, workArea.width); - const height = Math.min(WINDOW_SIZES[mode].height, workArea.height); - win.setBounds( - { +function isStoredWindowState(value: unknown): value is StoredWindowState { + if (!value || typeof value !== "object") return false; + const state = value as Partial; + return ( + [state.x, state.y, state.width, state.height].every(Number.isFinite) && + typeof state.maximized === "boolean" + ); +} + +/** Restore saved bounds onto the closest current display. This also recovers + * from a monitor being disconnected between launches. */ +function loadWindowState(): StoredWindowState { + const primaryWorkArea = screen.getPrimaryDisplay().workArea; + const fallbackWidth = Math.min(DEFAULT_WINDOW_SIZE.width, primaryWorkArea.width); + const fallbackHeight = Math.min(DEFAULT_WINDOW_SIZE.height, primaryWorkArea.height); + const fallback: StoredWindowState = { + width: fallbackWidth, + height: fallbackHeight, + x: Math.round(primaryWorkArea.x + (primaryWorkArea.width - fallbackWidth) / 2), + y: Math.round(primaryWorkArea.y + (primaryWorkArea.height - fallbackHeight) / 2), + maximized: false, + }; + + const statePath = join(app.getPath("userData"), WINDOW_STATE_FILE); + if (!existsSync(statePath)) return fallback; + try { + const parsed: unknown = JSON.parse(readFileSync(statePath, "utf8")); + if (!isStoredWindowState(parsed)) return fallback; + const workArea = screen.getDisplayMatching(parsed).workArea; + const width = Math.min(Math.max(Math.round(parsed.width), MIN_SIZE.width), workArea.width); + const height = Math.min( + Math.max(Math.round(parsed.height), MIN_SIZE.height), + workArea.height + ); + return { width, height, - x: Math.round( - clamp( - current.x + (current.width - width) / 2, - workArea.x, - workArea.x + workArea.width - width - ) - ), - y: Math.round( - clamp( - current.y + (current.height - height) / 2, - workArea.y, - workArea.y + workArea.height - height - ) - ), - }, - true // animate (macOS) - ); + x: Math.round(clamp(parsed.x, workArea.x, workArea.x + workArea.width - width)), + y: Math.round(clamp(parsed.y, workArea.y, workArea.y + workArea.height - height)), + maximized: parsed.maximized, + }; + } catch (error) { + console.warn("Could not restore the saved window bounds.", error); + return fallback; + } +} + +function saveWindowState(win: BrowserWindow): void { + if (win.isDestroyed()) return; + const bounds = win.getNormalBounds(); + const state: StoredWindowState = { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + maximized: win.isMaximized(), + }; + try { + writeFileSync( + join(app.getPath("userData"), WINDOW_STATE_FILE), + JSON.stringify(state), + "utf8" + ); + } catch (error) { + console.warn("Could not save the window bounds.", error); + } } /** Set once the app is really terminating, so the close interception below @@ -219,8 +258,12 @@ function applyWindowMode(win: BrowserWindow, mode: WindowMode): void { let quitting = false; function createWindow(): BrowserWindow { + const restoredState = loadWindowState(); const win = new BrowserWindow({ - ...WINDOW_SIZES.compact, + x: restoredState.x, + y: restoredState.y, + width: restoredState.width, + height: restoredState.height, minWidth: MIN_SIZE.width, minHeight: MIN_SIZE.height, // Light by default — appearance is a user preference in the renderer. @@ -248,7 +291,26 @@ function createWindow(): BrowserWindow { }); windowModes.set(win, "compact"); - win.once("ready-to-show", () => win.show()); + win.once("ready-to-show", () => { + if (restoredState.maximized) win.maximize(); + win.show(); + }); + + let saveBoundsTimer: ReturnType | null = null; + const scheduleWindowStateSave = () => { + if (saveBoundsTimer) clearTimeout(saveBoundsTimer); + saveBoundsTimer = setTimeout(() => { + saveBoundsTimer = null; + saveWindowState(win); + }, 250); + }; + win.on("resize", scheduleWindowStateSave); + win.on("move", scheduleWindowStateSave); + win.on("maximize", scheduleWindowStateSave); + win.on("unmaximize", scheduleWindowStateSave); + win.on("closed", () => { + if (saveBoundsTimer) clearTimeout(saveBoundsTimer); + }); // A reload tears down the listener the renderer registered; make it re-announce. win.webContents.on("did-start-navigation", (event) => { @@ -262,6 +324,7 @@ function createWindow(): BrowserWindow { // next close (already on the upload screen) is a real close. Guarded on the // renderer being live, so an unresponsive page can still be closed. win.on("close", (event) => { + saveWindowState(win); if (quitting) return; if (windowModes.get(win) !== "expanded") return; if (!readyRenderers.has(win.webContents)) return; @@ -323,7 +386,7 @@ if (!gotLock) { ipcMain.on("window:set-mode", (event, mode: unknown) => { if (mode !== "compact" && mode !== "expanded") return; const win = BrowserWindow.fromWebContents(event.sender); - if (win) applyWindowMode(win, mode); + if (win) windowModes.set(win, mode); }); ipcMain.handle( "window:is-full-screen", diff --git a/electron/preload.ts b/electron/preload.ts index 2b54b7e..1b52205 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -4,7 +4,7 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from "electron"; * Minimal bridge for the renderer. Rescript's UI is still a normal web * surface; we only expose host metadata so the page can adapt chrome / skip * the COI service worker (headers come from the app:// protocol instead), - * plus the few window controls the page drives (sizing, title-bar state). + * plus the few window controls the page drives (project mode, title-bar state). */ contextBridge.exposeInMainWorld("rescriptDesktop", { platform: process.platform as NodeJS.Platform, @@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld("rescriptDesktop", { chrome: process.versions.chrome, node: process.versions.node, }, - /** Switch between the compact upload window and the full editor window. */ + /** Tell the host whether a project is open so native Close keeps its two-step behavior. */ setWindowMode: (mode: "compact" | "expanded") => { ipcRenderer.send("window:set-mode", mode); }, diff --git a/types/rescript-desktop.d.ts b/types/rescript-desktop.d.ts index afeaf4f..5bd67c3 100644 --- a/types/rescript-desktop.d.ts +++ b/types/rescript-desktop.d.ts @@ -1,4 +1,4 @@ -/** Resting sizes the Electron shell switches between. */ +/** Renderer modes used by the Electron shell's two-step native Close behavior. */ export type WindowMode = "compact" | "expanded"; /** Actions the native File menu delegates to the renderer over IPC. Opening the @@ -18,7 +18,7 @@ export interface RescriptDesktop { chrome: string; node: string; }; - /** Resize the shell: "compact" for the upload screen, "expanded" for the editor. */ + /** Report whether the upload screen or editor is active; this does not resize the window. */ setWindowMode: (mode: WindowMode) => void; /** Mirror the telemetry opt-out to the main process, which gates its own reporting. */ setTelemetryEnabled: (enabled: boolean) => void;