pasted-content-menu.tsx 6.3 KB

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