layout-dropdown-button.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. import { memo, ReactNode, useCallback, forwardRef } from 'react'
  2. import {
  3. Dropdown as BS3Dropdown,
  4. MenuItem,
  5. MenuItemProps,
  6. } from 'react-bootstrap'
  7. import { Spinner } from 'react-bootstrap-5'
  8. import {
  9. Dropdown,
  10. DropdownItem,
  11. DropdownMenu,
  12. DropdownToggle,
  13. } from '@/features/ui/components/bootstrap-5/dropdown-menu'
  14. import { Trans, useTranslation } from 'react-i18next'
  15. import Tooltip from '../../../shared/components/tooltip'
  16. import Icon from '../../../shared/components/icon'
  17. import IconChecked from '../../../shared/components/icon-checked'
  18. import ControlledDropdown from '../../../shared/components/controlled-dropdown'
  19. import {
  20. IdeLayout,
  21. IdeView,
  22. useLayoutContext,
  23. } from '../../../shared/context/layout-context'
  24. import * as eventTracking from '../../../infrastructure/event-tracking'
  25. import useEventListener from '../../../shared/hooks/use-event-listener'
  26. import { DetachRole } from '@/shared/context/detach-context'
  27. import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
  28. import MaterialIcon from '@/shared/components/material-icon'
  29. function IconPlaceholder() {
  30. return <Icon type="" fw />
  31. }
  32. function IconRefresh() {
  33. return <Icon type="refresh" fw spin />
  34. }
  35. function IconLayout() {
  36. return <Icon type="columns" fw />
  37. }
  38. function IconSplit() {
  39. return <Icon type="columns" fw />
  40. }
  41. function IconDetach() {
  42. return <Icon type="window-restore" fw />
  43. }
  44. function IconEditorOnly() {
  45. return <Icon type="code" fw />
  46. }
  47. function IconPdfOnly() {
  48. return <Icon type="file-pdf-o" fw />
  49. }
  50. const isActiveDropdownItem = ({
  51. iconFor,
  52. pdfLayout,
  53. view,
  54. detachRole,
  55. }: {
  56. iconFor: string
  57. pdfLayout: IdeLayout
  58. view: IdeView | null
  59. detachRole?: DetachRole
  60. }) => {
  61. if (detachRole === 'detacher' || view === 'history') {
  62. return false
  63. }
  64. if (
  65. iconFor === 'editorOnly' &&
  66. pdfLayout === 'flat' &&
  67. (view === 'editor' || view === 'file')
  68. ) {
  69. return true
  70. } else if (iconFor === 'pdfOnly' && pdfLayout === 'flat' && view === 'pdf') {
  71. return true
  72. } else if (iconFor === 'sideBySide' && pdfLayout === 'sideBySide') {
  73. return true
  74. }
  75. return false
  76. }
  77. function IconCheckmark(props: Parameters<typeof isActiveDropdownItem>[0]) {
  78. return isActiveDropdownItem(props) ? <IconChecked /> : <IconPlaceholder />
  79. }
  80. function LayoutMenuItem({
  81. checkmark,
  82. icon,
  83. text,
  84. ...props
  85. }: {
  86. checkmark: ReactNode
  87. icon: ReactNode
  88. text: string | ReactNode
  89. } & MenuItemProps) {
  90. return (
  91. <MenuItem {...props}>
  92. <div className="layout-menu-item">
  93. <div className="layout-menu-item-start">
  94. <div>{checkmark}</div>
  95. <div>{icon}</div>
  96. <div>{text}</div>
  97. </div>
  98. </div>
  99. </MenuItem>
  100. )
  101. }
  102. function EnhancedDropdownItem({
  103. active,
  104. ...props
  105. }: React.ComponentProps<typeof DropdownItem>) {
  106. return (
  107. <DropdownItem
  108. active={active}
  109. aria-current={active}
  110. trailingIcon={active ? 'check' : null}
  111. {...props}
  112. />
  113. )
  114. }
  115. const LayoutDropdownToggleButton = forwardRef<
  116. HTMLButtonElement,
  117. {
  118. onClick: (e: React.MouseEvent<HTMLButtonElement>) => void
  119. }
  120. >(({ onClick, ...props }, ref) => {
  121. const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
  122. eventTracking.sendMB('navigation-clicked-layout')
  123. onClick(e)
  124. }
  125. return <button {...props} ref={ref} onClick={handleClick} />
  126. })
  127. LayoutDropdownToggleButton.displayName = 'LayoutDropdownToggleButton'
  128. function DetachDisabled() {
  129. const { t } = useTranslation()
  130. return (
  131. <Tooltip
  132. id="detach-disabled"
  133. description={t('your_browser_does_not_support_this_feature')}
  134. overlayProps={{ placement: 'left' }}
  135. >
  136. <LayoutMenuItem
  137. disabled
  138. checkmark={<IconPlaceholder />}
  139. icon={<IconDetach />}
  140. text={t('pdf_in_separate_tab')}
  141. />
  142. </Tooltip>
  143. )
  144. }
  145. function LayoutDropdownButton() {
  146. const { t } = useTranslation()
  147. const {
  148. reattach,
  149. detach,
  150. detachIsLinked,
  151. detachRole,
  152. changeLayout,
  153. view,
  154. pdfLayout,
  155. } = useLayoutContext()
  156. const handleDetach = useCallback(() => {
  157. detach()
  158. eventTracking.sendMB('project-layout-detach')
  159. }, [detach])
  160. const handleReattach = useCallback(() => {
  161. if (detachRole !== 'detacher') {
  162. return
  163. }
  164. reattach()
  165. eventTracking.sendMB('project-layout-reattach')
  166. }, [detachRole, reattach])
  167. // reattach when the PDF pane opens
  168. useEventListener('ui:pdf-open', handleReattach)
  169. const handleChangeLayout = useCallback(
  170. (newLayout: IdeLayout, newView?: IdeView) => {
  171. handleReattach()
  172. changeLayout(newLayout, newView)
  173. eventTracking.sendMB('project-layout-change', {
  174. layout: newLayout,
  175. view: newView,
  176. })
  177. },
  178. [changeLayout, handleReattach]
  179. )
  180. const processing = !detachIsLinked && detachRole === 'detacher'
  181. // bsStyle is required for Dropdown.Toggle, but we will override style
  182. return (
  183. <>
  184. {processing && (
  185. <div aria-live="assertive" className="sr-only">
  186. {t('layout_processing')}
  187. </div>
  188. )}
  189. <BootstrapVersionSwitcher
  190. bs3={
  191. <ControlledDropdown
  192. id="layout-dropdown"
  193. onMainButtonClick={() => {
  194. eventTracking.sendMB('navigation-clicked-layout')
  195. }}
  196. className="toolbar-item layout-dropdown"
  197. pullRight
  198. >
  199. <BS3Dropdown.Toggle className="btn-full-height" bsStyle="link">
  200. {processing ? <IconRefresh /> : <IconLayout />}
  201. <span className="toolbar-label">{t('layout')}</span>
  202. </BS3Dropdown.Toggle>
  203. <BS3Dropdown.Menu className="layout-dropdown-list">
  204. <LayoutMenuItem
  205. onSelect={() => handleChangeLayout('sideBySide')}
  206. checkmark={
  207. <IconCheckmark
  208. iconFor="sideBySide"
  209. pdfLayout={pdfLayout}
  210. view={view}
  211. detachRole={detachRole}
  212. />
  213. }
  214. icon={<IconSplit />}
  215. text={t('editor_and_pdf')}
  216. />
  217. <LayoutMenuItem
  218. onSelect={() => handleChangeLayout('flat', 'editor')}
  219. checkmark={
  220. <IconCheckmark
  221. iconFor="editorOnly"
  222. pdfLayout={pdfLayout}
  223. view={view}
  224. detachRole={detachRole}
  225. />
  226. }
  227. icon={<IconEditorOnly />}
  228. text={
  229. <Trans
  230. i18nKey="editor_only_hide_pdf"
  231. components={[
  232. <span key="editor_only_hide_pdf" className="subdued" />,
  233. ]}
  234. />
  235. }
  236. />
  237. <LayoutMenuItem
  238. onSelect={() => handleChangeLayout('flat', 'pdf')}
  239. checkmark={
  240. <IconCheckmark
  241. iconFor="pdfOnly"
  242. pdfLayout={pdfLayout}
  243. view={view}
  244. detachRole={detachRole}
  245. />
  246. }
  247. icon={<IconPdfOnly />}
  248. text={
  249. <Trans
  250. i18nKey="pdf_only_hide_editor"
  251. components={[
  252. <span key="pdf_only_hide_editor" className="subdued" />,
  253. ]}
  254. />
  255. }
  256. />
  257. {'BroadcastChannel' in window ? (
  258. <LayoutMenuItem
  259. onSelect={() => handleDetach()}
  260. checkmark={
  261. detachRole === 'detacher' ? (
  262. detachIsLinked ? (
  263. <IconChecked />
  264. ) : (
  265. <IconRefresh />
  266. )
  267. ) : (
  268. <IconPlaceholder />
  269. )
  270. }
  271. icon={<IconDetach />}
  272. text={t('pdf_in_separate_tab')}
  273. />
  274. ) : (
  275. <DetachDisabled />
  276. )}
  277. </BS3Dropdown.Menu>
  278. </ControlledDropdown>
  279. }
  280. bs5={
  281. <Dropdown className="toolbar-item layout-dropdown" align="end">
  282. <DropdownToggle
  283. id="layout-dropdown-btn"
  284. className="btn-full-height"
  285. as={LayoutDropdownToggleButton}
  286. >
  287. {processing ? (
  288. <Spinner
  289. animation="border"
  290. aria-hidden="true"
  291. size="sm"
  292. role="status"
  293. />
  294. ) : (
  295. <MaterialIcon type="dock_to_right" className="align-middle" />
  296. )}
  297. <span className="toolbar-label">{t('layout')}</span>
  298. </DropdownToggle>
  299. <DropdownMenu className="layout-dropdown-list">
  300. <EnhancedDropdownItem
  301. onClick={() => handleChangeLayout('sideBySide')}
  302. active={isActiveDropdownItem({
  303. iconFor: 'sideBySide',
  304. pdfLayout,
  305. view,
  306. detachRole,
  307. })}
  308. leadingIcon="dock_to_right"
  309. >
  310. {t('editor_and_pdf')}
  311. </EnhancedDropdownItem>
  312. <EnhancedDropdownItem
  313. onClick={() => handleChangeLayout('flat', 'editor')}
  314. active={isActiveDropdownItem({
  315. iconFor: 'editorOnly',
  316. pdfLayout,
  317. view,
  318. detachRole,
  319. })}
  320. leadingIcon="code"
  321. >
  322. <Trans
  323. i18nKey="editor_only_hide_pdf"
  324. components={[
  325. <span
  326. key="editor_only_hide_pdf"
  327. className="ms-1 subdued"
  328. />,
  329. ]}
  330. />
  331. </EnhancedDropdownItem>
  332. <EnhancedDropdownItem
  333. onClick={() => handleChangeLayout('flat', 'pdf')}
  334. active={isActiveDropdownItem({
  335. iconFor: 'pdfOnly',
  336. pdfLayout,
  337. view,
  338. detachRole,
  339. })}
  340. leadingIcon="picture_as_pdf"
  341. >
  342. <Trans
  343. i18nKey="pdf_only_hide_editor"
  344. components={[
  345. <span
  346. key="pdf_only_hide_editor"
  347. className="ms-1 subdued"
  348. />,
  349. ]}
  350. />
  351. </EnhancedDropdownItem>
  352. {'BroadcastChannel' in window ? (
  353. <EnhancedDropdownItem
  354. onClick={() => handleDetach()}
  355. active={detachRole === 'detacher' && detachIsLinked}
  356. trailingIcon={
  357. detachRole === 'detacher' ? (
  358. detachIsLinked ? (
  359. 'check'
  360. ) : (
  361. <span className="spinner-container">
  362. <Spinner
  363. animation="border"
  364. aria-hidden="true"
  365. size="sm"
  366. role="status"
  367. />
  368. <span className="visually-hidden">
  369. {t('loading')}
  370. </span>
  371. </span>
  372. )
  373. ) : null
  374. }
  375. leadingIcon="select_window"
  376. >
  377. {t('pdf_in_separate_tab')}
  378. </EnhancedDropdownItem>
  379. ) : (
  380. <DetachDisabled />
  381. )}
  382. </DropdownMenu>
  383. </Dropdown>
  384. }
  385. />
  386. </>
  387. )
  388. }
  389. export default memo(LayoutDropdownButton)