layout-context.tsx 8.8 KB

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