pasted-content-menu.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import {
  2. FC,
  3. HTMLProps,
  4. PropsWithChildren,
  5. useEffect,
  6. useRef,
  7. useState,
  8. } from 'react'
  9. import Icon from '../../../../shared/components/icon'
  10. import { Overlay, Popover } from 'react-bootstrap'
  11. import { useTranslation } from 'react-i18next'
  12. import { EditorView } from '@codemirror/view'
  13. import { PastedContent } from '../../extensions/visual/pasted-content'
  14. import useEventListener from '../../../../shared/hooks/use-event-listener'
  15. import SplitTestBadge from '../../../../shared/components/split-test-badge'
  16. import { useSplitTestContext } from '../../../../shared/context/split-test-context'
  17. const isMac = /Mac/.test(window.navigator?.platform)
  18. export const PastedContentMenu: FC<{
  19. insertPastedContent: (
  20. view: EditorView,
  21. pastedContent: PastedContent,
  22. formatted: boolean
  23. ) => void
  24. pastedContent: PastedContent
  25. view: EditorView
  26. formatted: boolean
  27. }> = ({ view, insertPastedContent, pastedContent, formatted }) => {
  28. const [menuOpen, setMenuOpen] = useState(false)
  29. const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
  30. const { t } = useTranslation()
  31. const { splitTestInfo } = useSplitTestContext()
  32. const feedbackURL = splitTestInfo['paste-html']?.badgeInfo?.url
  33. // record whether the Shift key is currently down, for use in the `paste` event handler
  34. const shiftRef = useRef(false)
  35. useEventListener('keydown', (event: KeyboardEvent) => {
  36. shiftRef.current = event.shiftKey
  37. })
  38. useEffect(() => {
  39. if (menuOpen) {
  40. const abortController = new AbortController()
  41. view.dom.addEventListener(
  42. 'paste',
  43. event => {
  44. event.preventDefault()
  45. event.stopPropagation()
  46. insertPastedContent(view, pastedContent, !shiftRef.current)
  47. setMenuOpen(false)
  48. },
  49. { signal: abortController.signal, capture: true }
  50. )
  51. return () => {
  52. abortController.abort()
  53. }
  54. }
  55. }, [view, menuOpen, pastedContent, insertPastedContent])
  56. // TODO: keyboard navigation
  57. return (
  58. <>
  59. <button
  60. ref={toggleButtonRef}
  61. type="button"
  62. id="pasted-content-menu-button"
  63. aria-haspopup="true"
  64. aria-expanded={menuOpen}
  65. aria-controls="pasted-content-menu"
  66. aria-label={t('paste_options')}
  67. className="ol-cm-pasted-content-menu-toggle"
  68. tabIndex={0}
  69. onMouseDown={event => event.preventDefault()}
  70. onClick={() => setMenuOpen(isOpen => !isOpen)}
  71. style={{ userSelect: 'none' }}
  72. >
  73. <Icon type="clipboard" fw />
  74. <Icon type="caret-down" fw />
  75. </button>
  76. {menuOpen && (
  77. <Overlay
  78. show
  79. onHide={() => setMenuOpen(false)}
  80. animation={false}
  81. container={view.scrollDOM}
  82. containerPadding={0}
  83. placement="bottom"
  84. rootClose
  85. target={toggleButtonRef.current ?? undefined}
  86. >
  87. <Popover
  88. id="popover-pasted-content-menu"
  89. className="ol-cm-pasted-content-menu-popover"
  90. >
  91. <div
  92. className="ol-cm-pasted-content-menu"
  93. id="pasted-content-menu"
  94. role="menu"
  95. aria-labelledby="pasted-content-menu-button"
  96. >
  97. <MenuItem
  98. onClick={() => {
  99. insertPastedContent(view, pastedContent, true)
  100. setMenuOpen(false)
  101. }}
  102. >
  103. <span style={{ visibility: formatted ? 'visible' : 'hidden' }}>
  104. <Icon type="check" fw />
  105. </span>
  106. <span className="ol-cm-pasted-content-menu-item-label">
  107. {t('paste_with_formatting')}
  108. </span>
  109. <span className="ol-cm-pasted-content-menu-item-shortcut">
  110. {isMac ? '⌘V' : 'Ctrl+V'}
  111. </span>
  112. </MenuItem>
  113. <MenuItem
  114. onClick={() => {
  115. insertPastedContent(view, pastedContent, false)
  116. setMenuOpen(false)
  117. }}
  118. >
  119. <span style={{ visibility: formatted ? 'hidden' : 'visible' }}>
  120. <Icon type="check" fw />
  121. </span>
  122. <span className="ol-cm-pasted-content-menu-item-label">
  123. {t('paste_without_formatting')}
  124. </span>
  125. <span className="ol-cm-pasted-content-menu-item-shortcut">
  126. {isMac ? '⇧⌘V' : 'Ctrl+Shift+V'}
  127. </span>
  128. </MenuItem>
  129. <MenuItem
  130. style={{ borderTop: '1px solid #eee' }}
  131. onClick={() => {
  132. window.open(feedbackURL, '_blank')
  133. setMenuOpen(false)
  134. }}
  135. >
  136. <SplitTestBadge
  137. splitTestName="paste-html"
  138. displayOnVariants={['enabled']}
  139. />
  140. <span className="ol-cm-pasted-content-menu-item-label">
  141. {t('give_feedback')}
  142. </span>
  143. </MenuItem>
  144. </div>
  145. </Popover>
  146. </Overlay>
  147. )}
  148. </>
  149. )
  150. }
  151. const MenuItem = ({
  152. children,
  153. ...buttonProps
  154. }: PropsWithChildren<HTMLProps<HTMLButtonElement>>) => (
  155. <button
  156. {...buttonProps}
  157. type="button"
  158. role="menuitem"
  159. className="ol-cm-pasted-content-menu-item"
  160. >
  161. {children}
  162. </button>
  163. )