Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 5 additions & 30 deletions components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -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<HTMLInputElement>(null);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -398,15 +382,6 @@ export default function Editor() {
<Timeline />
</>
)}
{modeTransitioning && (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-zinc-50 dark:bg-zinc-950"
aria-busy="true"
aria-live="polite"
>
<LogoLoader size={44} />
</div>
)}
{isElectron && (
// Present in the layout tree (not display:none) so the native menu's
// click() reliably opens the picker.
Expand Down
145 changes: 104 additions & 41 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<WindowMode, { width: number; height: number }> = {
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;
Expand Down Expand Up @@ -179,48 +185,85 @@ 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<StoredWindowState>;
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
* doesn't swallow the quit. */
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.
Expand Down Expand Up @@ -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<typeof setTimeout> | 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) => {
Expand All @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
},
Expand Down
4 changes: 2 additions & 2 deletions types/rescript-desktop.d.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down