pasted-content-menu.tsx 6.4 KB

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