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
23 changes: 20 additions & 3 deletions src/app/tools/sql-designer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
NodeChange,
EdgeChange,
ConnectionMode,
MarkerType
MarkerType,
useReactFlow
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dynamic from 'next/dynamic';
Expand All @@ -31,6 +32,7 @@ import { CopyButton } from '@/components/ui/CopyButton';
import { Select } from '@/components/ui/Select';
import { useAppStore } from '@/lib/store/useStore';
import { defineEditorThemes } from '@/tools/editor-theme';
import { useFullscreenOverlay } from '@/lib/hooks/useFullscreenOverlay';
import { TableNode } from './TableNode';
import {
initialNodes,
Expand Down Expand Up @@ -64,6 +66,20 @@ function FlowDesigner() {
const [exportFormat, setExportFormat] = React.useState<ExportFormat>('sql');
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [isLoaded, setIsLoaded] = React.useState(false);
const { fitView } = useReactFlow();

const exitFullscreen = React.useCallback(() => setIsFullscreen(false), []);
useFullscreenOverlay(isFullscreen, exitFullscreen);

// Re-fit the diagram once the canvas has resized after entering/exiting fullscreen
// (compares against the previous value so the initial mount, incl. Strict Mode re-runs, is skipped)
const prevFullscreenRef = React.useRef(isFullscreen);
React.useEffect(() => {
if (prevFullscreenRef.current === isFullscreen) return;
prevFullscreenRef.current = isFullscreen;
const frame = requestAnimationFrame(() => fitView({ padding: 0.2 }));
return () => cancelAnimationFrame(frame);
}, [isFullscreen, fitView]);

const visualChangePending = React.useRef(true);
const isTypingRef = React.useRef(false);
Expand Down Expand Up @@ -364,7 +380,8 @@ function FlowDesigner() {
<div
className={
isFullscreen
? 'fixed inset-0 z-50 bg-bg-primary p-4 lg:p-6 grid grid-cols-1 lg:grid-cols-12 gap-6 h-screen overflow-hidden'
? // z-45: above the header (z-40), below modals and toasts (z-50). Below lg the overlay scrolls with fixed row heights.
'fixed inset-0 z-[45] bg-bg-primary p-4 lg:p-6 grid grid-cols-1 lg:grid-cols-12 grid-rows-[70dvh_80dvh] lg:grid-rows-none gap-6 overflow-y-auto lg:overflow-hidden'
: 'grid grid-cols-1 lg:grid-cols-12 gap-6 h-[720px]'
}
>
Expand All @@ -383,7 +400,7 @@ function FlowDesigner() {
<Button
variant="secondary"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
onClick={() => setIsFullscreen((prev) => !prev)}
className="shadow-lg bg-bg-elevated border border-border"
>
{isFullscreen ? <Minimize2 className="w-4 h-4 mr-1" /> : <Maximize2 className="w-4 h-4 mr-1" />}
Expand Down
9 changes: 8 additions & 1 deletion src/components/layout/ShortcutsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import * as React from 'react';
import { Modal } from '@/components/ui/Modal';
import { useAppStore } from '@/lib/store/useStore';
import { Keyboard, Command, SunMoon, Search, CornerDownLeft, Sparkles, X } from 'lucide-react';
import { Keyboard, Command, SunMoon, Search, CornerDownLeft, Sparkles, X, Maximize2, Minimize2 } from 'lucide-react';

export function ShortcutsModal() {
const { shortcutsOpen, setShortcutsOpen, toggleTheme } = useAppStore();
Expand Down Expand Up @@ -53,6 +53,13 @@ export function ShortcutsModal() {
{ key: 'Esc', desc: 'Dismiss command palette', icon: X },
],
},
{
title: 'Fullscreen Editor',
shortcuts: [
{ key: 'F', desc: 'Toggle fullscreen in split-pane tools (when not typing)', icon: Maximize2 },
{ key: 'Esc', desc: 'Exit fullscreen', icon: Minimize2 },
],
},
];

return (
Expand Down
5 changes: 4 additions & 1 deletion src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ export function Modal({ open, onClose, children, title }: ModalProps) {

React.useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key !== 'Escape') return;
// Mark as handled so window-level Esc listeners (e.g. fullscreen overlays) don't also fire
e.preventDefault();
onClose();
};
if (open) {
document.addEventListener('keydown', handleEscape);
Expand Down
151 changes: 72 additions & 79 deletions src/components/ui/SplitPanesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as React from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
import { createPortal } from 'react-dom';
import { useFullscreenOverlay } from '@/lib/hooks/useFullscreenOverlay';

export const SplitPanesContext = React.createContext({ isFullscreen: false });
export const useSplitPanes = () => React.useContext(SplitPanesContext);
Expand All @@ -19,103 +19,96 @@ export interface SplitPanesViewProps {

export function SplitPanesView({ leftPane, rightPane, toolbarContent }: SplitPanesViewProps) {
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [mounted, setMounted] = React.useState(false);

React.useEffect(() => {
setMounted(true);
}, []);
const overlayRef = React.useRef<HTMLDivElement>(null);

const toggleFullscreen = React.useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);

const exitFullscreen = React.useCallback(() => setIsFullscreen(false), []);

useFullscreenOverlay(isFullscreen, exitFullscreen);

React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
setIsFullscreen(false);
return;
if (e.key !== 'f' && e.key !== 'F') return;
// Leave modified/repeated/IME keys alone so ⌘F / Ctrl+F (browser find) still work
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey || e.repeat || e.isComposing || e.defaultPrevented) return;

const active = document.activeElement;
if (active instanceof HTMLElement) {
const isTyping =
active.isContentEditable ||
active.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null;
if (isTyping) return;
}

if (e.key.toLowerCase() === 'f') {
const activeTag = document.activeElement?.tagName.toLowerCase();
const isInput = activeTag === 'input' || activeTag === 'textarea' || (document.activeElement as HTMLElement)?.isContentEditable;
if (!isInput) {
e.preventDefault();
toggleFullscreen();
}
}
// Don't toggle underneath another open dialog (our own fullscreen overlay is fine)
const hasForeignModal = Array.from(document.querySelectorAll('[aria-modal="true"]')).some(
(el) => el !== overlayRef.current
);
if (hasForeignModal) return;

e.preventDefault();
toggleFullscreen();
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isFullscreen, toggleFullscreen]);

React.useEffect(() => {
if (isFullscreen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isFullscreen]);
}, [toggleFullscreen]);

const renderContent = (content: RenderPane) =>
typeof content === 'function' ? content(isFullscreen) : content;

const innerContent = (
<div className={cn(
"flex flex-col w-full",
isFullscreen ? "h-full p-4 gap-4 [&_textarea]:!text-[15px] [&_textarea]:!leading-relaxed [&_input]:!text-[15px] [&_.font-mono]:!text-[15px] [&_.font-mono]:!leading-relaxed" : "gap-6"
)}>
{/* Top Bar for Toolbar & Fullscreen Toggle */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1 flex flex-wrap sm:flex-nowrap items-center gap-4 overflow-x-auto scrollbar-hide">
{renderContent(toolbarContent)}
</div>
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={toggleFullscreen}
icon={isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
title={isFullscreen ? "Exit Fullscreen (Esc)" : "Fullscreen (F)"}
>
{isFullscreen ? "Exit" : "Fullscreen"}
</Button>
</div>
</div>

{/* Split Panes */}
<div className={cn(
"grid grid-cols-1 gap-6",
isFullscreen ? "lg:grid-cols-2 flex-1 min-h-0" : "lg:grid-cols-2" // On fullscreen, strictly 50/50 and take full height
)}>
<div className={cn("flex flex-col h-full min-h-0", isFullscreen && "overflow-hidden")}>
{renderContent(leftPane)}
</div>
<div className={cn("flex flex-col h-full min-h-0", isFullscreen && "overflow-hidden")}>
{renderContent(rightPane)}
// Same element tree in both modes so toggling never remounts the panes (keeps search, scroll, undo state).
// Fullscreen sits at z-45: above the header (z-40), below modals and toasts (z-50).
return (
<SplitPanesContext.Provider value={{ isFullscreen }}>
<div
ref={overlayRef}
className={cn(
isFullscreen && 'fixed inset-0 z-[45] bg-bg-primary flex flex-col overflow-y-auto lg:overflow-hidden animate-fade-in'
)}
role={isFullscreen ? 'dialog' : undefined}
aria-modal={isFullscreen ? true : undefined}
aria-label={isFullscreen ? 'Fullscreen editor' : undefined}
>
<div className={cn(
"flex flex-col w-full",
isFullscreen ? "min-h-full shrink-0 lg:h-full p-4 gap-4 [&_textarea]:!text-[15px] [&_textarea]:!leading-relaxed [&_input]:!text-[15px] [&_.font-mono]:!text-[15px] [&_.font-mono]:!leading-relaxed" : "gap-6"
)}>
{/* Top Bar for Toolbar & Fullscreen Toggle */}
<div className="flex items-center justify-between gap-4">
<div className="flex-1 flex flex-wrap sm:flex-nowrap items-center gap-4 overflow-x-auto scrollbar-hide">
{renderContent(toolbarContent)}
</div>
<div className="flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={toggleFullscreen}
icon={isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
title={isFullscreen ? "Exit Fullscreen (Esc)" : "Fullscreen (F)"}
>
{isFullscreen ? "Exit" : "Fullscreen"}
</Button>
</div>
</div>

{/* Split Panes — on lg fullscreen, strictly 50/50 filling the viewport; below lg the overlay scrolls */}
<div className={cn(
"grid grid-cols-1 gap-6 lg:grid-cols-2",
isFullscreen && "lg:grid-rows-1 lg:flex-1 lg:min-h-0"
)}>
<div className={cn("flex flex-col h-full min-h-0", isFullscreen && "lg:overflow-auto")}>
{renderContent(leftPane)}
</div>
<div className={cn("flex flex-col h-full min-h-0", isFullscreen && "lg:overflow-auto")}>
{renderContent(rightPane)}
</div>
</div>
</div>
</div>
</div>
);

const wrappedContent = (
<SplitPanesContext.Provider value={{ isFullscreen }}>
{innerContent}
</SplitPanesContext.Provider>
);

if (isFullscreen && mounted) {
return createPortal(
<div className="fixed inset-0 z-[100] bg-bg-primary flex flex-col animate-in fade-in zoom-in-95 duration-200">
{wrappedContent}
</div>,
document.body
);
}

return wrappedContent;
}
35 changes: 35 additions & 0 deletions src/lib/hooks/useFullscreenOverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import * as React from 'react';

/**
* Shared behaviour for in-page "fullscreen" overlays (CSS fixed overlays, not the
* browser Fullscreen API): Escape closes the overlay and body scroll is locked
* while it is open. Does nothing while closed.
*
* Escape presses already handled elsewhere (e.g. a Modal calling preventDefault)
* are ignored so a single Esc only dismisses the topmost layer.
*/
export function useFullscreenOverlay(isOpen: boolean, onClose: () => void) {
const onCloseRef = React.useRef(onClose);

React.useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);

React.useEffect(() => {
if (!isOpen) return;

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape' || e.defaultPrevented) return;
onCloseRef.current();
};

const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', handleKeyDown);

return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = prevOverflow;
};
}, [isOpen]);
}