pasted-content-menu.tsx 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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 { sendMB } from '@/infrastructure/event-tracking'
  15. import MaterialIcon from '@/shared/components/material-icon'
  16. import OLOverlay from '@/shared/components/ol/ol-overlay'
  17. import OLPopover from '@/shared/components/ol/ol-popover'
  18. import { isMac } from '@/shared/utils/os'
  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(
  35. 'keydown',
  36. useCallback((event: KeyboardEvent) => {
  37. shiftRef.current = event.shiftKey
  38. }, [])
  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. <MaterialIcon type="content_copy" />
  94. <MaterialIcon type="expand_more" />
  95. </button>
  96. {menuOpen && (
  97. <OLOverlay
  98. show
  99. onHide={() => setMenuOpen(false)}
  100. transition={false}
  101. container={view.scrollDOM}
  102. containerPadding={0}
  103. placement="bottom"
  104. rootClose
  105. target={toggleButtonRef?.current}
  106. >
  107. <OLPopover
  108. id="popover-pasted-content-menu"
  109. className="ol-cm-pasted-content-menu-popover"
  110. >
  111. <div
  112. className="ol-cm-pasted-content-menu"
  113. id="pasted-content-menu"
  114. role="menu"
  115. aria-labelledby="pasted-content-menu-button"
  116. >
  117. <MenuItem
  118. onClick={() => {
  119. insertPastedContent(view, pastedContent, true)
  120. sendMB('pasted-content-menu-click', {
  121. action: 'paste-with-formatting',
  122. })
  123. setMenuOpen(false)
  124. }}
  125. >
  126. <span style={{ visibility: formatted ? 'visible' : 'hidden' }}>
  127. <MaterialIcon type="check" />
  128. </span>
  129. <span className="ol-cm-pasted-content-menu-item-label">
  130. {t('paste_with_formatting')}
  131. </span>
  132. <span className="ol-cm-pasted-content-menu-item-shortcut">
  133. {isMac ? '⌘V' : 'Ctrl+V'}
  134. </span>
  135. </MenuItem>
  136. <MenuItem
  137. onClick={() => {
  138. insertPastedContent(view, pastedContent, false)
  139. sendMB('pasted-content-menu-click', {
  140. action: 'paste-without-formatting',
  141. })
  142. setMenuOpen(false)
  143. }}
  144. >
  145. <span style={{ visibility: formatted ? 'hidden' : 'visible' }}>
  146. <MaterialIcon type="check" />
  147. </span>
  148. <span className="ol-cm-pasted-content-menu-item-label">
  149. {t('paste_without_formatting')}
  150. </span>
  151. <span className="ol-cm-pasted-content-menu-item-shortcut">
  152. {isMac ? '⇧⌘V' : 'Ctrl+Shift+V'}
  153. </span>
  154. </MenuItem>
  155. </div>
  156. </OLPopover>
  157. </OLOverlay>
  158. )}
  159. </>
  160. )
  161. }
  162. const MenuItem = ({
  163. children,
  164. ...buttonProps
  165. }: PropsWithChildren<HTMLProps<HTMLButtonElement>>) => (
  166. <button
  167. {...buttonProps}
  168. type="button"
  169. role="menuitem"
  170. className="ol-cm-pasted-content-menu-item"
  171. >
  172. {children}
  173. </button>
  174. )