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
65 changes: 65 additions & 0 deletions frontend/src/components/ui/SplitScreenLayout.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,69 @@
height: 12px;
cursor: row-resize;
}

/* Mobile hamburger drawer trigger */
.mobileDrawerToggle {
display: inline-flex;
flex-direction: column;
gap: 4px;
background: none;
border: none;
padding: 6px;
cursor: pointer;
z-index: 40;
}

.hamburgerLine {
width: 20px;
height: 2px;
background-color: currentColor;
border-radius: 2px;
transition: transform 0.2s ease, opacity 0.2s ease;
}

/* Mobile drawer overlay + panel */
.mobileDrawerOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
z-index: 50;
display: flex;
align-items: flex-start;
}

.mobileDrawerPanel {
position: relative;
width: 72vw;
max-width: 320px;
height: 100vh;
background: var(--color-bg-primary, #ffffff);
color: var(--color-text-primary, #000000);
border-right: 1px solid var(--color-border-medium, #d0d0d0);
transform: translateX(0);
transition: transform 0.3s ease;
z-index: 51;
overflow-y: auto;
padding: 16px;
}

html.dark .mobileDrawerPanel,
html.oled .mobileDrawerPanel {
background: var(--color-bg-primary, #000000);
color: var(--color-text-primary, #ffffff);
border-right-color: var(--color-border-medium, #3d3d3d);
}

.mobileDrawerClose {
position: absolute;
top: 12px;
right: 12px;
background: transparent;
border: none;
font-size: 20px;
line-height: 1;
cursor: pointer;
padding: 4px;
}
}
80 changes: 67 additions & 13 deletions frontend/src/components/ui/SplitScreenLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,33 @@ interface SplitScreenLayoutProps {
rightPanel: React.ReactNode;
minWidthPercent?: number;
initialWidthPercent?: number;
storageKey?: string;
}

export default function SplitScreenLayout({
leftPanel,
rightPanel,
minWidthPercent = 20,
initialWidthPercent = 50,
storageKey = 'split-screen-layout',
}: SplitScreenLayoutProps) {
const [leftWidth, setLeftWidth] = useState(initialWidthPercent);
// Persist pane sizing across page reloads (Acceptance Criteria: persists sizing across reloads)
const [leftWidth, setLeftWidth] = useState(() => {
if (typeof window === 'undefined') return initialWidthPercent;
try {
const stored = window.localStorage.getItem(storageKey);
if (stored) {
const parsed = parseFloat(stored);
if (!Number.isNaN(parsed)) return parsed;
}
} catch {
// localStorage may be unavailable; fall back to default
}
return initialWidthPercent;
});

const [isDragging, setIsDragging] = useState(false);
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false);

const containerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | null>(null);
Expand All @@ -34,7 +51,6 @@ export default function SplitScreenLayout({
const onDrag = useCallback((e: MouseEvent | TouchEvent) => {
if (!isDragging || !containerRef.current) return;

// Throttle style calculations via requestAnimationFrame to ensure fluid 60fps movement
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
Expand All @@ -43,19 +59,22 @@ export default function SplitScreenLayout({
const containerRect = containerRef.current!.getBoundingClientRect();
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;

// Calculate new percentage relative to viewport container width
const currentX = clientX - containerRect.left;
let newWidthPercent = (currentX / containerRect.width) * 100;

// Restrict boundaries to prevent panels from totally collapsing out of sight
if (newWidthPercent < minWidthPercent) newWidthPercent = minWidthPercent;
if (newWidthPercent > 100 - minWidthPercent) newWidthPercent = 100 - minWidthPercent;

setLeftWidth(newWidthPercent);
// Persist to localStorage so sizing survives reloads
try {
window.localStorage.setItem(storageKey, String(newWidthPercent));
} catch {
// ignore
}
});
}, [isDragging, minWidthPercent]);
}, [isDragging, minWidthPercent, storageKey]);

// Handle binding window-level events during drag operations so cursor doesn't lose tracking
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', onDrag);
Expand All @@ -78,18 +97,53 @@ export default function SplitScreenLayout({
};
}, [isDragging, onDrag, stopDragging]);

// Close mobile drawer when resizing to a desktop breakpoint
useEffect(() => {
const mq = window.matchMedia('(min-width: 768px)');
const handler = () => setIsMobileDrawerOpen(false);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);

const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;

return (
<div
ref={containerRef}
className={`${styles.container} ${isDragging ? styles.isDragging : ''}`}
>
{/* Reader / Left Panel */}
<div
className={styles.panel}
style={{ width: `calc(${leftWidth}% - 4px)` }}
>
{leftPanel}
</div>
{/* Mobile sidebar hamburger drawer trigger (Easy sub-task) */}
{isMobile && (
<button
className={styles.mobileDrawerToggle}
onClick={() => setIsMobileDrawerOpen((prev) => !prev)}
aria-label={isMobileDrawerOpen ? 'Close navigation drawer' : 'Open navigation drawer'}
aria-expanded={isMobileDrawerOpen}
>
<span className={styles.hamburgerLine} />
<span className={styles.hamburgerLine} />
<span className={styles.hamburgerLine} />
</button>
)}

{/* Mobile drawer panel */}
{isMobile && isMobileDrawerOpen && (
<div
className={styles.mobileDrawerOverlay}
onClick={() => setIsMobileDrawerOpen(false)}
>
<div className={styles.mobileDrawerPanel} onClick={(e) => e.stopPropagation()}>
<button
className={styles.mobileDrawerClose}
onClick={() => setIsMobileDrawerOpen(false)}
aria-label="Close drawer"
>
✕
</button>
{leftPanel}
</div>
</div>
)}

{/* Horizontal Divider Bar */}
<div
Expand Down