layout-context.tsx 8.9 KB

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