layout-dropdown-button.tsx 13 KB

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