layout-context.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  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. export type IdeLayout = 'sideBySide' | 'flat'
  28. export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
  29. export type LayoutContextOwnStates = {
  30. view: IdeView | null
  31. chatIsOpen: boolean
  32. reviewPanelOpen: boolean
  33. miniReviewPanelVisible: boolean
  34. leftMenuShown: boolean
  35. loadingStyleSheet: boolean
  36. pdfLayout: IdeLayout
  37. projectSearchIsOpen: boolean
  38. openFile: BinaryFile | null
  39. }
  40. export type LayoutContextValue = LayoutContextOwnStates & {
  41. reattach: () => void
  42. detach: () => void
  43. detachIsLinked: boolean
  44. detachRole: DetachRole
  45. changeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
  46. setView: (view: IdeView | null) => void
  47. setChatIsOpen: Dispatch<SetStateAction<LayoutContextValue['chatIsOpen']>>
  48. setReviewPanelOpen: Dispatch<
  49. SetStateAction<LayoutContextValue['reviewPanelOpen']>
  50. >
  51. setMiniReviewPanelVisible: Dispatch<
  52. SetStateAction<LayoutContextValue['miniReviewPanelVisible']>
  53. >
  54. setLeftMenuShown: Dispatch<
  55. SetStateAction<LayoutContextValue['leftMenuShown']>
  56. >
  57. setLoadingStyleSheet: Dispatch<
  58. SetStateAction<LayoutContextValue['loadingStyleSheet']>
  59. >
  60. pdfPreviewOpen: boolean
  61. setProjectSearchIsOpen: Dispatch<SetStateAction<boolean>>
  62. setOpenFile: Dispatch<SetStateAction<BinaryFile | null>>
  63. restoreView: () => void
  64. handleChangeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
  65. handleDetach: () => void
  66. }
  67. const debugPdfDetach = getMeta('ol-debugPdfDetach')
  68. export const LayoutContext = createContext<LayoutContextValue | undefined>(
  69. undefined
  70. )
  71. function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
  72. localStorage.setItem(
  73. 'pdf.layout',
  74. pdfLayout === 'sideBySide' ? 'split' : 'flat'
  75. )
  76. }
  77. const reviewPanelStorageKey = `ui.reviewPanelOpen.${getMeta('ol-project_id')}`
  78. export const LayoutProvider: FC<React.PropsWithChildren> = ({ children }) => {
  79. // what to show in the "flat" view (editor or pdf)
  80. const [view, _setView] = useState<IdeView | null>('editor')
  81. const [openFile, setOpenFile] = useState<BinaryFile | null>(null)
  82. const historyToggleEmitter = useScopeEventEmitter('history:toggle', true)
  83. const { isOpen: railIsOpen, setIsOpen: setRailIsOpen } = useRailContext()
  84. const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen)
  85. // Whether we came from a file or a document when we left the ide
  86. const lastIdeView = useRef<IdeView>('editor')
  87. const { sendEvent } = useEditorAnalytics()
  88. const setView = useCallback(
  89. (value: IdeView | null) => {
  90. _setView(oldValue => {
  91. // ensure that the "history:toggle" event is broadcast when switching in or out of history view
  92. if (value === 'history' || oldValue === 'history') {
  93. historyToggleEmitter()
  94. }
  95. if (value === 'history') {
  96. setPrevRailIsOpen(railIsOpen)
  97. setRailIsOpen(true)
  98. }
  99. if (oldValue === 'history') {
  100. setRailIsOpen(prevRailIsOpen)
  101. }
  102. if (value === 'editor' || value === 'file') {
  103. lastIdeView.current = value
  104. }
  105. return value
  106. })
  107. },
  108. [
  109. _setView,
  110. setRailIsOpen,
  111. historyToggleEmitter,
  112. prevRailIsOpen,
  113. setPrevRailIsOpen,
  114. railIsOpen,
  115. ]
  116. )
  117. const restoreView = useCallback(() => {
  118. setView(lastIdeView.current ?? 'editor')
  119. }, [setView])
  120. // whether the chat pane is open
  121. const [chatIsOpen, setChatIsOpen] = usePersistedState<boolean>(
  122. 'ui.chatOpen',
  123. false
  124. )
  125. // whether the review pane is open
  126. const [reviewPanelOpen, setReviewPanelOpen] = usePersistedState<boolean>(
  127. reviewPanelStorageKey,
  128. false
  129. )
  130. // whether the review pane is collapsed
  131. const [miniReviewPanelVisible, setMiniReviewPanelVisible] =
  132. useState<boolean>(false)
  133. // whether the menu pane is open
  134. const [leftMenuShown, setLeftMenuShown] = useState<boolean>(false)
  135. // whether the project search is open
  136. const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(false)
  137. useEventListener(
  138. 'ui.toggle-left-menu',
  139. useCallback(
  140. (event: CustomEvent<boolean>) => {
  141. setLeftMenuShown(event.detail)
  142. },
  143. [setLeftMenuShown]
  144. )
  145. )
  146. // TODO ide-redesign-cleanup: remove this listener as we have an equivalent in rail-context
  147. useEventListener(
  148. 'ui.toggle-review-panel',
  149. useCallback(() => {
  150. setReviewPanelOpen(open => !open)
  151. }, [setReviewPanelOpen])
  152. )
  153. // TODO ide-redesign-cleanup: remove this listener as we have an equivalent in rail-context
  154. useEventListener(
  155. 'keydown',
  156. useCallback((event: KeyboardEvent) => {
  157. if (
  158. (isMac ? event.metaKey : event.ctrlKey) &&
  159. event.shiftKey &&
  160. event.code === 'KeyF'
  161. ) {
  162. event.preventDefault()
  163. sendSearchEvent('search-open', {
  164. searchType: 'full-project',
  165. method: 'keyboard',
  166. })
  167. setProjectSearchIsOpen(true)
  168. }
  169. }, [])
  170. )
  171. // whether to display the editor and preview side-by-side or full-width ("flat")
  172. const [pdfLayout, setPdfLayout] = useState<IdeLayout>('sideBySide')
  173. // whether stylesheet on theme is loading
  174. const [loadingStyleSheet, setLoadingStyleSheet] = useState(false)
  175. const changeLayout = useCallback(
  176. (newLayout: IdeLayout, newView: IdeView = 'editor') => {
  177. const targetView = newLayout === 'sideBySide' ? 'editor' : newView
  178. setPdfLayout(newLayout)
  179. if (targetView === 'editor') {
  180. restoreView()
  181. } else {
  182. setView(targetView)
  183. }
  184. setLayoutInLocalStorage(newLayout)
  185. },
  186. [setPdfLayout, setView, restoreView]
  187. )
  188. // Force codemirror to reposition all tooltips to prevent an issue
  189. // where tooltips would sometimes show on top of the pdf preview
  190. // https://github.com/overleaf/internal/issues/23840
  191. useEffect(() => {
  192. if (view === 'pdf' && pdfLayout === 'flat') {
  193. repositionAllTooltips()
  194. }
  195. }, [view, pdfLayout])
  196. const {
  197. reattach,
  198. detach,
  199. isLinking: detachIsLinking,
  200. isLinked: detachIsLinked,
  201. role: detachRole,
  202. isRedundant: detachIsRedundant,
  203. } = useDetachLayout()
  204. const pdfPreviewOpen =
  205. pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
  206. useEffect(() => {
  207. if (debugPdfDetach) {
  208. debugConsole.warn('Layout Effect', {
  209. detachIsRedundant,
  210. detachRole,
  211. detachIsLinking,
  212. detachIsLinked,
  213. })
  214. }
  215. if (detachRole !== 'detacher') return // not in a PDF detacher layout
  216. if (detachIsRedundant) {
  217. changeLayout('sideBySide')
  218. return
  219. }
  220. if (detachIsLinking || detachIsLinked) {
  221. // the tab is linked to a detached tab (or about to be linked); show
  222. // editor only
  223. changeLayout('flat', 'editor')
  224. }
  225. }, [
  226. detachIsRedundant,
  227. detachRole,
  228. detachIsLinking,
  229. detachIsLinked,
  230. changeLayout,
  231. ])
  232. const handleDetach = useCallback(() => {
  233. detach()
  234. sendEvent('project-layout-detach')
  235. }, [detach, sendEvent])
  236. const handleReattach = useCallback(() => {
  237. if (detachRole !== 'detacher') {
  238. return
  239. }
  240. reattach()
  241. sendEvent('project-layout-reattach')
  242. }, [detachRole, reattach, sendEvent])
  243. const handleChangeLayout = useCallback(
  244. (newLayout: IdeLayout, newView?: IdeView) => {
  245. handleReattach()
  246. changeLayout(newLayout, newView)
  247. sendEvent('project-layout-change', {
  248. layout: newLayout,
  249. view: newView,
  250. })
  251. },
  252. [changeLayout, handleReattach, sendEvent]
  253. )
  254. useEventListener(
  255. 'keydown',
  256. useCallback(
  257. (event: KeyboardEvent) => {
  258. if (
  259. isMac &&
  260. event.metaKey &&
  261. event.ctrlKey &&
  262. !event.shiftKey &&
  263. !event.altKey
  264. ) {
  265. switch (event.code) {
  266. case 'ArrowLeft': // Editor only
  267. event.preventDefault()
  268. handleChangeLayout('flat', 'editor')
  269. break
  270. case 'ArrowRight': // PDF only
  271. event.preventDefault()
  272. handleChangeLayout('flat', 'pdf')
  273. break
  274. case 'ArrowDown': // Split view
  275. event.preventDefault()
  276. handleChangeLayout('sideBySide')
  277. break
  278. case 'ArrowUp': // Open PDF in separate tab (detach)
  279. event.preventDefault()
  280. if ('BroadcastChannel' in window && detachRole !== 'detacher') {
  281. handleDetach()
  282. }
  283. break
  284. }
  285. }
  286. },
  287. [detachRole, handleChangeLayout, handleDetach]
  288. )
  289. )
  290. const value = useMemo<LayoutContextValue>(
  291. () => ({
  292. reattach,
  293. detach,
  294. detachIsLinked,
  295. detachRole,
  296. changeLayout,
  297. chatIsOpen,
  298. leftMenuShown,
  299. openFile,
  300. pdfLayout,
  301. pdfPreviewOpen,
  302. projectSearchIsOpen,
  303. setProjectSearchIsOpen,
  304. reviewPanelOpen,
  305. miniReviewPanelVisible,
  306. loadingStyleSheet,
  307. setChatIsOpen,
  308. setLeftMenuShown,
  309. setOpenFile,
  310. setPdfLayout,
  311. setReviewPanelOpen,
  312. setMiniReviewPanelVisible,
  313. setLoadingStyleSheet,
  314. setView,
  315. view,
  316. restoreView,
  317. handleChangeLayout,
  318. handleDetach,
  319. }),
  320. [
  321. reattach,
  322. detach,
  323. detachIsLinked,
  324. detachRole,
  325. changeLayout,
  326. chatIsOpen,
  327. leftMenuShown,
  328. openFile,
  329. pdfLayout,
  330. pdfPreviewOpen,
  331. projectSearchIsOpen,
  332. setProjectSearchIsOpen,
  333. reviewPanelOpen,
  334. miniReviewPanelVisible,
  335. loadingStyleSheet,
  336. setChatIsOpen,
  337. setLeftMenuShown,
  338. setOpenFile,
  339. setPdfLayout,
  340. setReviewPanelOpen,
  341. setMiniReviewPanelVisible,
  342. setLoadingStyleSheet,
  343. setView,
  344. view,
  345. restoreView,
  346. handleChangeLayout,
  347. handleDetach,
  348. ]
  349. )
  350. return (
  351. <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
  352. )
  353. }
  354. export function useLayoutContext() {
  355. const context = useContext(LayoutContext)
  356. if (!context) {
  357. throw new Error('useLayoutContext is only available inside LayoutProvider')
  358. }
  359. return context
  360. }