layout-context.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import {
  2. createContext,
  3. useContext,
  4. useCallback,
  5. useMemo,
  6. useEffect,
  7. Dispatch,
  8. SetStateAction,
  9. FC,
  10. useState,
  11. useRef,
  12. } from 'react'
  13. import useDetachLayout from '../hooks/use-detach-layout'
  14. import localStorage from '../../infrastructure/local-storage'
  15. import getMeta from '../../utils/meta'
  16. import { DetachRole } from './detach-context'
  17. import { debugConsole } from '@/utils/debugging'
  18. import { BinaryFile } from '@/features/file-view/types/binary-file'
  19. import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
  20. import useEventListener from '@/shared/hooks/use-event-listener'
  21. import { isMac } from '@/shared/utils/os'
  22. import { sendSearchEvent } from '@/features/event-tracking/search-events'
  23. import { useRailContext } from '@/features/ide-react/context/rail-context'
  24. import usePersistedState from '@/shared/hooks/use-persisted-state'
  25. import { repositionAllTooltips } from '@/features/source-editor/extensions/tooltips-reposition'
  26. import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
  27. import { useFeatureFlag } from './split-test-context'
  28. export type IdeLayout = 'sideBySide' | 'flat'
  29. export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
  30. export type LayoutContextOwnStates = {
  31. view: IdeView | null
  32. chatIsOpen: boolean
  33. reviewPanelOpen: boolean
  34. miniReviewPanelVisible: boolean
  35. settingsShown: boolean
  36. loadingStyleSheet: boolean
  37. pdfLayout: IdeLayout
  38. projectSearchIsOpen: boolean
  39. openFile: BinaryFile | null
  40. focusMode: boolean
  41. }
  42. export type LayoutContextValue = LayoutContextOwnStates & {
  43. reattach: () => void
  44. detach: () => void
  45. detachIsLinked: boolean
  46. detachRole: DetachRole
  47. changeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
  48. setView: (view: IdeView | null) => void
  49. setChatIsOpen: Dispatch<SetStateAction<LayoutContextValue['chatIsOpen']>>
  50. setReviewPanelOpen: Dispatch<
  51. SetStateAction<LayoutContextValue['reviewPanelOpen']>
  52. >
  53. setMiniReviewPanelVisible: Dispatch<
  54. SetStateAction<LayoutContextValue['miniReviewPanelVisible']>
  55. >
  56. setSettingsShown: Dispatch<
  57. SetStateAction<LayoutContextValue['settingsShown']>
  58. >
  59. setLoadingStyleSheet: Dispatch<
  60. SetStateAction<LayoutContextValue['loadingStyleSheet']>
  61. >
  62. pdfPreviewOpen: boolean
  63. setProjectSearchIsOpen: Dispatch<SetStateAction<boolean>>
  64. setOpenFile: Dispatch<SetStateAction<BinaryFile | null>>
  65. restoreView: () => void
  66. handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
  67. handleDetach: () => void
  68. setFocusMode: Dispatch<SetStateAction<LayoutContextValue['focusMode']>>
  69. }
  70. const debugPdfDetach = getMeta('ol-debugPdfDetach')
  71. export const LayoutContext = createContext<LayoutContextValue | undefined>(
  72. undefined
  73. )
  74. function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
  75. localStorage.setItem(
  76. 'pdf.layout',
  77. pdfLayout === 'sideBySide' ? 'split' : 'flat'
  78. )
  79. }
  80. const reviewPanelStorageKey = `ui.reviewPanelOpen.${getMeta('ol-project_id')}`
  81. export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
  82. // what to show in the "flat" view (editor or pdf)
  83. const [view, _setView] = useState<IdeView | null>('editor')
  84. const [openFile, setOpenFile] = useState<BinaryFile | null>(null)
  85. const historyToggleEmitter = useScopeEventEmitter('history:toggle', true)
  86. const { isOpen: railIsOpen, setIsOpen: setRailIsOpen } = useRailContext()
  87. const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen)
  88. // Whether we came from a file or a document when we left the ide
  89. const lastIdeView = useRef<IdeView>('editor')
  90. const { sendEvent } = useEditorAnalytics()
  91. const setView = useCallback(
  92. (value: IdeView | null) => {
  93. _setView(oldValue => {
  94. // ensure that the "history:toggle" event is broadcast when switching in or out of history view
  95. if (value === 'history' || oldValue === 'history') {
  96. historyToggleEmitter()
  97. }
  98. if (value === 'history') {
  99. setPrevRailIsOpen(railIsOpen)
  100. setRailIsOpen(true)
  101. }
  102. if (oldValue === 'history') {
  103. setRailIsOpen(prevRailIsOpen)
  104. }
  105. if (value === 'editor' || value === 'file') {
  106. lastIdeView.current = value
  107. }
  108. return value
  109. })
  110. },
  111. [
  112. _setView,
  113. setRailIsOpen,
  114. historyToggleEmitter,
  115. prevRailIsOpen,
  116. setPrevRailIsOpen,
  117. railIsOpen,
  118. ]
  119. )
  120. const restoreView = useCallback(() => {
  121. setView(lastIdeView.current ?? 'editor')
  122. }, [setView])
  123. // whether the chat pane is open
  124. const [chatIsOpen, setChatIsOpen] = usePersistedState<boolean>(
  125. 'ui.chatOpen',
  126. false
  127. )
  128. // whether the review pane is open
  129. const [reviewPanelOpen, setReviewPanelOpen] = usePersistedState<boolean>(
  130. reviewPanelStorageKey,
  131. false
  132. )
  133. // whether the review pane is collapsed
  134. const [miniReviewPanelVisible, setMiniReviewPanelVisible] =
  135. useState<boolean>(false)
  136. // whether the settings modal is open
  137. const [settingsShown, setSettingsShown] = useState<boolean>(false)
  138. // whether the project search is open
  139. const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(false)
  140. // whether to display the editor and preview side-by-side or full-width ("flat")
  141. const [pdfLayout, setPdfLayout] = useState<IdeLayout>('sideBySide')
  142. const [persistedFocusMode, setPersistedFocusMode] =
  143. usePersistedState<boolean>('ui.focus-mode', false)
  144. const focusModeEnabled = useFeatureFlag('focus-mode')
  145. const focusMode = focusModeEnabled && persistedFocusMode
  146. const setFocusMode = useCallback(
  147. (value: SetStateAction<boolean>) => {
  148. if (focusModeEnabled) {
  149. const newValue = typeof value === 'function' ? value(focusMode) : value
  150. setPersistedFocusMode(newValue)
  151. sendEvent('project-layout-change', {
  152. layout: pdfLayout,
  153. view: view ?? undefined,
  154. focusMode: newValue,
  155. })
  156. }
  157. },
  158. [
  159. focusMode,
  160. focusModeEnabled,
  161. pdfLayout,
  162. sendEvent,
  163. setPersistedFocusMode,
  164. view,
  165. ]
  166. )
  167. useEventListener(
  168. 'ui.toggle-settings',
  169. useCallback(
  170. (event: CustomEvent<boolean>) => {
  171. setSettingsShown(event.detail)
  172. },
  173. [setSettingsShown]
  174. )
  175. )
  176. // TODO ide-redesign-cleanup: remove this listener as we have an equivalent in rail-context
  177. useEventListener(
  178. 'ui.toggle-review-panel',
  179. useCallback(() => {
  180. setReviewPanelOpen(open => !open)
  181. }, [setReviewPanelOpen])
  182. )
  183. useEventListener(
  184. 'ui.toggle-focus-mode',
  185. useCallback(() => {
  186. setFocusMode(mode => !mode)
  187. }, [setFocusMode])
  188. )
  189. // Global keyboard shortcut handlers
  190. useEffect(() => {
  191. const handleKeyDown = (event: KeyboardEvent) => {
  192. // Cmd/Ctrl+Shift+F for full project search
  193. if (
  194. (isMac ? event.metaKey : event.ctrlKey) &&
  195. event.shiftKey &&
  196. event.key.toUpperCase() === 'F'
  197. ) {
  198. event.preventDefault()
  199. sendSearchEvent('search-open', {
  200. searchType: 'full-project',
  201. method: 'keyboard',
  202. })
  203. setProjectSearchIsOpen(true)
  204. }
  205. // Cmd/Ctrl+Shift+M for focus mode
  206. if (
  207. focusModeEnabled &&
  208. (isMac ? event.metaKey : event.ctrlKey) &&
  209. event.shiftKey &&
  210. event.key.toUpperCase() === 'M'
  211. ) {
  212. event.preventDefault()
  213. setFocusMode(mode => !mode)
  214. }
  215. }
  216. // Use capture phase to ensure we get the event even if something else stops propagation
  217. window.addEventListener('keydown', handleKeyDown, true)
  218. return () => {
  219. window.removeEventListener('keydown', handleKeyDown, true)
  220. }
  221. }, [focusModeEnabled, setFocusMode, setProjectSearchIsOpen])
  222. // whether stylesheet on theme is loading
  223. const [loadingStyleSheet, setLoadingStyleSheet] = useState(false)
  224. const changeLayout = useCallback(
  225. (newLayout: IdeLayout, newView: IdeView = 'editor') => {
  226. const targetView = newLayout === 'sideBySide' ? 'editor' : newView
  227. setPdfLayout(newLayout)
  228. if (targetView === 'editor') {
  229. restoreView()
  230. } else {
  231. setView(targetView)
  232. }
  233. setLayoutInLocalStorage(newLayout)
  234. },
  235. [setPdfLayout, setView, restoreView]
  236. )
  237. // Force codemirror to reposition all tooltips to prevent an issue
  238. // where tooltips would sometimes show on top of the pdf preview
  239. // https://github.com/overleaf/internal/issues/23840
  240. useEffect(() => {
  241. if (view === 'pdf' && pdfLayout === 'flat') {
  242. repositionAllTooltips()
  243. }
  244. }, [view, pdfLayout])
  245. const {
  246. reattach,
  247. detach,
  248. isLinking: detachIsLinking,
  249. isLinked: detachIsLinked,
  250. role: detachRole,
  251. isRedundant: detachIsRedundant,
  252. } = useDetachLayout()
  253. const pdfPreviewOpen =
  254. pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
  255. useEffect(() => {
  256. if (debugPdfDetach) {
  257. debugConsole.warn('Layout Effect', {
  258. detachIsRedundant,
  259. detachRole,
  260. detachIsLinking,
  261. detachIsLinked,
  262. })
  263. }
  264. if (detachRole !== 'detacher') return // not in a PDF detacher layout
  265. if (detachIsRedundant) {
  266. changeLayout('sideBySide')
  267. return
  268. }
  269. if (detachIsLinking || detachIsLinked) {
  270. // the tab is linked to a detached tab (or about to be linked); show
  271. // editor only
  272. changeLayout('flat', 'editor')
  273. }
  274. }, [
  275. detachIsRedundant,
  276. detachRole,
  277. detachIsLinking,
  278. detachIsLinked,
  279. changeLayout,
  280. ])
  281. const handleDetach = useCallback(() => {
  282. detach()
  283. sendEvent('project-layout-detach')
  284. }, [detach, sendEvent])
  285. const handleReattach = useCallback(() => {
  286. if (detachRole !== 'detacher') {
  287. return
  288. }
  289. reattach()
  290. sendEvent('project-layout-reattach')
  291. }, [detachRole, reattach, sendEvent])
  292. const handleChangeLayout = useCallback(
  293. (newLayout: IdeLayout, newView?: IdeView) => {
  294. handleReattach()
  295. changeLayout(newLayout, newView)
  296. sendEvent('project-layout-change', {
  297. layout: newLayout,
  298. view: newView,
  299. focusMode,
  300. })
  301. },
  302. [changeLayout, focusMode, handleReattach, sendEvent]
  303. )
  304. useEventListener(
  305. 'keydown',
  306. useCallback(
  307. (event: KeyboardEvent) => {
  308. if (
  309. isMac &&
  310. event.metaKey &&
  311. event.ctrlKey &&
  312. !event.shiftKey &&
  313. !event.altKey
  314. ) {
  315. switch (event.code) {
  316. case 'ArrowLeft': // Editor only
  317. event.preventDefault()
  318. handleChangeLayout('flat', 'editor')
  319. break
  320. case 'ArrowRight': // PDF only
  321. event.preventDefault()
  322. handleChangeLayout('flat', 'pdf')
  323. break
  324. case 'ArrowDown': // Split view
  325. event.preventDefault()
  326. handleChangeLayout('sideBySide')
  327. break
  328. case 'ArrowUp': // Open PDF in separate tab (detach)
  329. event.preventDefault()
  330. if ('BroadcastChannel' in window && detachRole !== 'detacher') {
  331. handleDetach()
  332. }
  333. break
  334. }
  335. }
  336. },
  337. [detachRole, handleChangeLayout, handleDetach]
  338. )
  339. )
  340. const value = useMemo<LayoutContextValue>(
  341. () => ({
  342. reattach,
  343. detach,
  344. detachIsLinked,
  345. detachRole,
  346. changeLayout,
  347. chatIsOpen,
  348. settingsShown,
  349. openFile,
  350. pdfLayout,
  351. pdfPreviewOpen,
  352. projectSearchIsOpen,
  353. setProjectSearchIsOpen,
  354. reviewPanelOpen,
  355. miniReviewPanelVisible,
  356. loadingStyleSheet,
  357. setChatIsOpen,
  358. setSettingsShown,
  359. setOpenFile,
  360. setPdfLayout,
  361. setReviewPanelOpen,
  362. setMiniReviewPanelVisible,
  363. setLoadingStyleSheet,
  364. setView,
  365. view,
  366. restoreView,
  367. handleChangeLayout,
  368. handleDetach,
  369. focusMode,
  370. setFocusMode,
  371. }),
  372. [
  373. reattach,
  374. detach,
  375. detachIsLinked,
  376. detachRole,
  377. changeLayout,
  378. chatIsOpen,
  379. settingsShown,
  380. openFile,
  381. pdfLayout,
  382. pdfPreviewOpen,
  383. projectSearchIsOpen,
  384. setProjectSearchIsOpen,
  385. reviewPanelOpen,
  386. miniReviewPanelVisible,
  387. loadingStyleSheet,
  388. setChatIsOpen,
  389. setSettingsShown,
  390. setOpenFile,
  391. setPdfLayout,
  392. setReviewPanelOpen,
  393. setMiniReviewPanelVisible,
  394. setLoadingStyleSheet,
  395. setView,
  396. view,
  397. restoreView,
  398. handleChangeLayout,
  399. handleDetach,
  400. focusMode,
  401. setFocusMode,
  402. ]
  403. )
  404. return (
  405. <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
  406. )
  407. }
  408. export function useLayoutContext() {
  409. const context = useContext(LayoutContext)
  410. if (!context) {
  411. throw new Error('useLayoutContext is only available inside LayoutProvider')
  412. }
  413. return context
  414. }