Skip to content
Merged
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
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"core:window:default",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-toggle-maximize",
"core:window:allow-set-fullscreen",
"core:window:allow-close",
Expand Down
54 changes: 48 additions & 6 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -854,17 +854,48 @@ function AppContent() {
const handleToggleAI = useCallback(() => setShowAIPanel((v) => !v), []);

// Toggle OS fullscreen (F11). The custom title bar deliberately stays
// visible so there's always an obvious way back (the same controls, plus
// F11 again); a one-time hint reinforces it for non-technical users. One
// Tauri call covers Windows, Linux, and macOS. FULLSCREEN-01.
// visible so there's always an obvious way back (its square button turns
// into "exit fullscreen", plus F11 again); a one-time hint reinforces it.
//
// Two Windows-specific footguns, both worked around here. (1) On a frameless
// (decorations:false) window, entering fullscreen while MAXIMIZED leaves a
// black bar where the taskbar was / overflows the right edge — a known tao
// bug. So we drop maximize first and restore it on exit. (2) isFullscreen()
// returns unreliable values for frameless windows, so F11 "wouldn't exit";
// we track the state ourselves instead of querying it. FULLSCREEN-01.
const [isFullscreen, setIsFullscreen] = useState(false);
const isFullscreenRef = useRef(false);
const wasMaximizedRef = useRef(false);
// Drops an opaque cover over the webview while the window resizes. The
// unmaximize→fullscreen step (and its reverse) physically resizes the window
// twice, so the content visibly reflows mid-transition — a jarring "snap".
// We snap the cover on instantly, let the OS settle behind it, then fade it
// out, so the change reads as a smooth dip instead of a jump.
const [fsTransition, setFsTransition] = useState(false);
const toggleFullscreen = useCallback(async () => {
try {
const w = Window.getCurrent();
const isFs = await w.isFullscreen();
await w.setFullscreen(!isFs);
if (!isFs) showToast("Fullscreen on — press F11 to exit", "info");
const next = !isFullscreenRef.current;
setFsTransition(true);
// Yield one frame so the cover actually paints before the window starts
// resizing underneath it — otherwise the first reflow frame leaks through.
await new Promise((r) => requestAnimationFrame(() => r(null)));
if (next) {
wasMaximizedRef.current = await w.isMaximized();
if (wasMaximizedRef.current) await w.unmaximize();
await w.setFullscreen(true);
showToast("Fullscreen on — press F11 to exit", "info");
} else {
await w.setFullscreen(false);
if (wasMaximizedRef.current) await w.maximize();
}
isFullscreenRef.current = next;
setIsFullscreen(next);
} catch {
/* browser dev mode — no Tauri window */
} finally {
// Reveal once the resize has settled; the cover fades out via CSS.
window.setTimeout(() => setFsTransition(false), 200);
}
}, [showToast]);

Expand Down Expand Up @@ -1255,6 +1286,8 @@ function AppContent() {
onExportError={handleExportError}
onToggleAI={aiEnabled ? handleToggleAI : undefined}
aiActive={showAIPanel}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
/>

{/* Startup update check; invisible unless an update is actually available. */}
Expand Down Expand Up @@ -1459,6 +1492,15 @@ function AppContent() {
</Suspense>
)}

{/* Fullscreen transition cover. Snaps opaque instantly (no fade-in) to
hide the mid-resize reflow, then fades out over 300ms to reveal the
settled layout. Sits above everything; pointer-events-none so it never
eats a click once it's transparent. */}
<div
aria-hidden="true"
className={`fixed inset-0 z-[200] bg-[var(--bg-primary)] pointer-events-none ${fsTransition ? "opacity-100" : "opacity-0 invisible transition-[opacity,visibility] duration-300"}`}
/>

{/* Loading overlay */}
{isLoading && (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-[var(--bg-primary)]/80 backdrop-blur-sm">
Expand Down
23 changes: 18 additions & 5 deletions src/components/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ interface TitleBarProps {
onExportError?: (format: string) => void;
onToggleAI?: () => void;
aiActive?: boolean;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
}

function TitleBarImpl({ fileName, isDirty, filePath, onOpenFile, onNewFile, getExportHtml, onExportSuccess, onExportError, onToggleAI, aiActive }: TitleBarProps) {
function TitleBarImpl({ fileName, isDirty, filePath, onOpenFile, onNewFile, getExportHtml, onExportSuccess, onExportError, onToggleAI, aiActive, isFullscreen, onToggleFullscreen }: TitleBarProps) {
const handleMinimize = async () => {
try {
const appWindow = Window.getCurrent();
Expand All @@ -28,6 +30,14 @@ function TitleBarImpl({ fileName, isDirty, filePath, onOpenFile, onNewFile, getE
};

const handleMaximize = async () => {
// While fullscreen, this button is the "exit fullscreen" control —
// toggling maximize underneath an active fullscreen is what produced
// the black-bar / stuck-taskbar state on Windows. Route it through the
// same fullscreen toggle (which also restores the prior maximize).
if (isFullscreen) {
onToggleFullscreen?.();
return;
}
try {
const appWindow = Window.getCurrent();
await appWindow.toggleMaximize();
Expand All @@ -50,9 +60,11 @@ function TitleBarImpl({ fileName, isDirty, filePath, onOpenFile, onNewFile, getE
try {
const appWindow = Window.getCurrent();
// Native title bars maximize on double-click; event.detail
// counts clicks within the double-click interval.
// counts clicks within the double-click interval. While fullscreen
// a double-click exits it (same reason as the maximize button).
if (event.detail === 2) {
await appWindow.toggleMaximize();
if (isFullscreen) onToggleFullscreen?.();
else await appWindow.toggleMaximize();
} else {
await appWindow.startDragging();
}
Expand Down Expand Up @@ -176,10 +188,11 @@ function TitleBarImpl({ fileName, isDirty, filePath, onOpenFile, onNewFile, getE
</button>
<button
onClick={handleMaximize}
aria-label="Maximize"
aria-label={isFullscreen ? "Exit fullscreen" : "Maximize"}
title={isFullscreen ? "Exit fullscreen (F11)" : "Maximize"}
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-[var(--bg-hover)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<span className="material-symbols-outlined text-[16px]">crop_square</span>
<span className="material-symbols-outlined text-[16px]">{isFullscreen ? "fullscreen_exit" : "crop_square"}</span>
</button>
<button
onClick={handleCloseClick}
Expand Down
Loading