pasted-content-menu.tsx 6.5 KB

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