pasted-content-menu.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. import {
  2. FC,
  3. HTMLProps,
  4. PropsWithChildren,
  5. useCallback,
  6. useEffect,
  7. useRef,
  8. useState,
  9. } from 'react'
  10. import Icon from '../../../../shared/components/icon'
  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 { FeedbackBadge } from '@/shared/components/feedback-badge'
  16. import { sendMB } from '@/infrastructure/event-tracking'
  17. import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
  18. import MaterialIcon from '@/shared/components/material-icon'
  19. import OLOverlay from '@/features/ui/components/ol/ol-overlay'
  20. import OLPopover from '@/features/ui/components/ol/ol-popover'
  21. const isMac = /Mac/.test(window.navigator?.platform)
  22. export const PastedContentMenu: FC<{
  23. insertPastedContent: (
  24. view: EditorView,
  25. pastedContent: PastedContent,
  26. formatted: boolean
  27. ) => void
  28. pastedContent: PastedContent
  29. view: EditorView
  30. formatted: boolean
  31. }> = ({ view, insertPastedContent, pastedContent, formatted }) => {
  32. const [menuOpen, setMenuOpen] = useState(false)
  33. const toggleButtonRef = useRef<HTMLButtonElement | null>(null)
  34. const { t } = useTranslation()
  35. // record whether the Shift key is currently down, for use in the `paste` event handler
  36. const shiftRef = useRef(false)
  37. useEventListener('keydown', (event: KeyboardEvent) => {
  38. shiftRef.current = event.shiftKey
  39. })
  40. // track interaction events
  41. const trackedEventsRef = useRef<Record<string, boolean>>({
  42. 'pasted-content-button-shown': false,
  43. 'pasted-content-button-click': false,
  44. })
  45. const trackEventOnce = useCallback((key: string) => {
  46. if (!trackedEventsRef.current[key]) {
  47. trackedEventsRef.current[key] = true
  48. sendMB(key)
  49. }
  50. }, [])
  51. useEffect(() => {
  52. if (menuOpen) {
  53. trackEventOnce('pasted-content-button-click')
  54. } else {
  55. trackEventOnce('pasted-content-button-shown')
  56. }
  57. }, [menuOpen, trackEventOnce])
  58. useEffect(() => {
  59. if (menuOpen) {
  60. const abortController = new AbortController()
  61. view.dom.addEventListener(
  62. 'paste',
  63. event => {
  64. event.preventDefault()
  65. event.stopPropagation()
  66. insertPastedContent(view, pastedContent, !shiftRef.current)
  67. setMenuOpen(false)
  68. },
  69. { signal: abortController.signal, capture: true }
  70. )
  71. return () => {
  72. abortController.abort()
  73. }
  74. }
  75. }, [view, menuOpen, pastedContent, insertPastedContent])
  76. // TODO: keyboard navigation
  77. return (
  78. <>
  79. <button
  80. ref={toggleButtonRef}
  81. type="button"
  82. id="pasted-content-menu-button"
  83. aria-haspopup="true"
  84. aria-expanded={menuOpen}
  85. aria-controls="pasted-content-menu"
  86. aria-label={t('paste_options')}
  87. className="ol-cm-pasted-content-menu-toggle"
  88. tabIndex={0}
  89. onMouseDown={event => event.preventDefault()}
  90. onClick={() => setMenuOpen(isOpen => !isOpen)}
  91. style={{ userSelect: 'none' }}
  92. >
  93. <BootstrapVersionSwitcher
  94. bs3={
  95. <>
  96. <Icon type="clipboard" fw />
  97. <Icon type="caret-down" fw />
  98. </>
  99. }
  100. bs5={
  101. <>
  102. <MaterialIcon type="content_copy" />
  103. <MaterialIcon type="expand_more" />
  104. </>
  105. }
  106. />
  107. </button>
  108. {menuOpen && (
  109. <OLOverlay
  110. show
  111. onHide={() => setMenuOpen(false)}
  112. transition={false}
  113. container={view.scrollDOM}
  114. containerPadding={0}
  115. placement="bottom"
  116. rootClose
  117. target={toggleButtonRef?.current}
  118. >
  119. <OLPopover
  120. id="popover-pasted-content-menu"
  121. className="ol-cm-pasted-content-menu-popover"
  122. >
  123. <div
  124. className="ol-cm-pasted-content-menu"
  125. id="pasted-content-menu"
  126. role="menu"
  127. aria-labelledby="pasted-content-menu-button"
  128. >
  129. <MenuItem
  130. onClick={() => {
  131. insertPastedContent(view, pastedContent, true)
  132. sendMB('pasted-content-menu-click', {
  133. action: 'paste-with-formatting',
  134. })
  135. setMenuOpen(false)
  136. }}
  137. >
  138. <span style={{ visibility: formatted ? 'visible' : 'hidden' }}>
  139. <Icon type="check" fw />
  140. </span>
  141. <span className="ol-cm-pasted-content-menu-item-label">
  142. {t('paste_with_formatting')}
  143. </span>
  144. <span className="ol-cm-pasted-content-menu-item-shortcut">
  145. {isMac ? '⌘V' : 'Ctrl+V'}
  146. </span>
  147. </MenuItem>
  148. <MenuItem
  149. onClick={() => {
  150. insertPastedContent(view, pastedContent, false)
  151. sendMB('pasted-content-menu-click', {
  152. action: 'paste-without-formatting',
  153. })
  154. setMenuOpen(false)
  155. }}
  156. >
  157. <span style={{ visibility: formatted ? 'hidden' : 'visible' }}>
  158. <Icon type="check" fw />
  159. </span>
  160. <span className="ol-cm-pasted-content-menu-item-label">
  161. {t('paste_without_formatting')}
  162. </span>
  163. <span className="ol-cm-pasted-content-menu-item-shortcut">
  164. {isMac ? '⇧⌘V' : 'Ctrl+Shift+V'}
  165. </span>
  166. </MenuItem>
  167. <MenuItem
  168. style={{ borderTop: '1px solid #eee' }}
  169. onClick={() => {
  170. window.open(
  171. 'https://docs.google.com/forms/d/e/1FAIpQLSc7WcHrwz9fnCkUP5hXyvkG3LkSYZiR3lVJWZ0o6uqNQYrV7Q/viewform',
  172. '_blank'
  173. )
  174. sendMB('pasted-content-menu-click', {
  175. action: 'give-feedback',
  176. })
  177. setMenuOpen(false)
  178. }}
  179. >
  180. <FeedbackBadge
  181. id="paste-html-feedback"
  182. url="https://docs.google.com/forms/d/e/1FAIpQLSc7WcHrwz9fnCkUP5hXyvkG3LkSYZiR3lVJWZ0o6uqNQYrV7Q/viewform"
  183. />
  184. <span className="ol-cm-pasted-content-menu-item-label">
  185. {t('give_feedback')}
  186. </span>
  187. </MenuItem>
  188. </div>
  189. </OLPopover>
  190. </OLOverlay>
  191. )}
  192. </>
  193. )
  194. }
  195. const MenuItem = ({
  196. children,
  197. ...buttonProps
  198. }: PropsWithChildren<HTMLProps<HTMLButtonElement>>) => (
  199. <button
  200. {...buttonProps}
  201. type="button"
  202. role="menuitem"
  203. className="ol-cm-pasted-content-menu-item"
  204. >
  205. {children}
  206. </button>
  207. )