pasted-content-menu.tsx 5.3 KB

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