Files
Verso/services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx
T
claude 51d0314c1c
Build and Deploy Verso / deploy (push) Successful in 10m17s
fix(mobile): detect touch devices with spoofed viewport width
Tor Browser and Firefox with privacy.resistFingerprinting report a
desktop-sized viewport (~980px) even on a real Android phone. This made
window.matchMedia('(max-width: 767px)') return false, so isMobile was
always false on Tor, leaving the editor in side-by-side (horizontal)
layout instead of the expected vertical stack.

Fix: add a secondary check using `(pointer: coarse) and (max-width:
1024px)`. Touch hardware is not spoofed by fingerprinting resistance,
so this reliably catches phones and tablets regardless of the reported
viewport width. Applied to both getInitialLayout() and the live
isMobile state in main-layout.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 14:31:56 +00:00

200 lines
7.3 KiB
TypeScript

import { Panel, PanelGroup } from 'react-resizable-panels'
import classNames from 'classnames'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
import PdfPreview from '@/features/pdf-preview/components/pdf-preview'
import { RailLayout } from '../rail/rail'
import { Toolbar } from '../toolbar/toolbar'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { useTranslation } from 'react-i18next'
import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
import { useLayoutContext } from '@/shared/context/layout-context'
import { ElementType, useEffect, useState } from 'react'
import EditorPanel from '../editor/editor-panel'
import { useRailContext } from '../../context/rail-context'
import HistoryContainer from '@/features/ide-react/components/history-container'
import { DefaultSynctexControl } from '@/features/pdf-preview/components/detach-synctex-control'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
const mainEditorLayoutPanels: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('mainEditorLayoutPanels')
const mainEditorLayoutModalsModules: Array<{
import: { default: ElementType }
path: string
}> = importOverleafModules('mainEditorLayoutModals')
// Bootstrap md breakpoint — below this we stack panels vertically on mobile
const MOBILE_MQ = '(max-width: 767px)'
// Secondary check: browsers that spoof viewport width (e.g. Tor Browser) still
// expose `pointer: coarse` for real touch hardware.
const TOUCH_MQ = '(pointer: coarse) and (max-width: 1024px)'
function detectMobile() {
return (
window.matchMedia(MOBILE_MQ).matches ||
window.matchMedia(TOUCH_MQ).matches
)
}
export default function MainLayout() {
const [resizing, setResizing] = useState(false)
const { resizing: railResizing } = useRailContext()
const {
togglePdfPane,
handlePdfPaneExpand,
handlePdfPaneCollapse,
setPdfIsOpen: setIsPdfOpen,
pdfIsOpen: isPdfOpen,
pdfPanelRef,
} = usePdfPane()
const { view, pdfLayout } = useLayoutContext()
const [isMobile, setIsMobile] = useState(detectMobile)
useEffect(() => {
const handler = () => setIsMobile(detectMobile())
const mq1 = window.matchMedia(MOBILE_MQ)
const mq2 = window.matchMedia(TOUCH_MQ)
mq1.addEventListener('change', handler)
mq2.addEventListener('change', handler)
return () => {
mq1.removeEventListener('change', handler)
mq2.removeEventListener('change', handler)
}
}, [])
// verticalSplit is always vertical; sideBySide becomes vertical on mobile
const isVertical =
pdfLayout === 'verticalSplit' ||
(pdfLayout === 'sideBySide' && isMobile)
const editorIsOpen =
view === 'editor' ||
view === 'file' ||
pdfLayout === 'sideBySide' ||
pdfLayout === 'verticalSplit'
const { t } = useTranslation()
return (
<div className="ide-redesign-main">
<Toolbar />
<div className="ide-redesign-body">
<PanelGroup
autoSaveId="ide-redesign-outer-layout"
direction="horizontal"
className={classNames('ide-redesign-inner', {
'ide-panel-group-resizing': resizing || railResizing,
})}
>
<RailLayout />
<Panel id="ide-redesign-editor-and-pdf-panel" order={2}>
<HistoryContainer />
<PanelGroup
key={isVertical ? 'vertical' : 'horizontal'}
autoSaveId={
isVertical
? // On mobile, skip autoSave: a stale collapsed PDF pane
// would fire onCollapse → changeLayout('flat'), overriding
// the verticalSplit default from getInitialLayout().
// The pdf.layout localStorage key already persists the
// user's explicit flat/open preference independently.
isMobile
? null
: 'ide-redesign-editor-and-pdf-panel-group-vertical'
: 'ide-redesign-editor-and-pdf-panel-group'
}
direction={isVertical ? 'vertical' : 'horizontal'}
className={classNames({
hidden: view === 'history',
})}
>
<Panel
id="ide-redesign-editor-panel"
order={1}
className={classNames({
hidden: !editorIsOpen || view === 'history',
})}
minSize={5}
defaultSize={50}
tagName="section"
aria-label={t('editor')}
>
<div className="ide-redesign-editor-container">
<EditorPanel />
</div>
</Panel>
{isVertical ? (
<VerticalResizeHandle
onDragging={setResizing}
className={classNames({
hidden: !editorIsOpen,
})}
/>
) : (
<HorizontalResizeHandle
resizable={pdfLayout === 'sideBySide'}
onDragging={setResizing}
onDoubleClick={togglePdfPane}
hitAreaMargins={{ coarse: 0, fine: 0 }}
className={classNames({
hidden: !editorIsOpen,
})}
>
<HorizontalToggler
id="ide-redesign-pdf-panel"
togglerType="east"
isOpen={isPdfOpen}
setIsOpen={setIsPdfOpen}
tooltipWhenOpen={t('tooltip_hide_pdf')}
tooltipWhenClosed={t('tooltip_show_pdf')}
/>
{pdfLayout === 'sideBySide' && (
<div className="synctex-controls">
<DefaultSynctexControl />
</div>
)}
</HorizontalResizeHandle>
)}
<Panel
collapsible
className={classNames('ide-redesign-pdf-container', {
hidden: view === 'history',
})}
id="ide-redesign-pdf-panel"
order={2}
defaultSize={50}
minSize={5}
ref={pdfPanelRef}
onExpand={handlePdfPaneExpand}
onCollapse={handlePdfPaneCollapse}
tagName="section"
aria-label={t('pdf_preview')}
>
<PdfPreview />
{pdfLayout === 'flat' && view === 'pdf' && (
<div className="synctex-controls" hidden>
<DefaultSynctexControl />
</div>
)}
</Panel>
</PanelGroup>
</Panel>
{mainEditorLayoutPanels.map(
({ import: { default: Component }, path }, i) => {
return <Component key={path} order={i + 3} />
}
)}
</PanelGroup>
</div>
{mainEditorLayoutModalsModules.map(
({ import: { default: Component }, path }) => (
<Component key={path} />
)
)}
</div>
)
}