layout-dropdown-button.tsx 13 KB

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