pasted-content-menu.tsx 6.8 KB

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