pasted-content.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. import {
  2. EditorSelection,
  3. Range,
  4. StateEffect,
  5. StateField,
  6. } from '@codemirror/state'
  7. import { Decoration, EditorView, WidgetType } from '@codemirror/view'
  8. import { undo } from '@codemirror/commands'
  9. import { ancestorNodeOfType } from '../../utils/tree-operations/ancestors'
  10. import ReactDOM from 'react-dom'
  11. import { PastedContentMenu } from '../../components/paste-html/pasted-content-menu'
  12. import { SplitTestProvider } from '../../../../shared/context/split-test-context'
  13. export type PastedContent = { latex: string; text: string }
  14. const pastedContentEffect =
  15. StateEffect.define<{ content: PastedContent; formatted: boolean }>()
  16. export const insertPastedContent = (
  17. view: EditorView,
  18. { latex, text }: PastedContent
  19. ) =>
  20. view.state.changeByRange(range => {
  21. // avoid pasting formatted content into a math container
  22. if (ancestorNodeOfType(view.state, range.anchor, '$MathContainer')) {
  23. return {
  24. range: EditorSelection.cursor(range.from + text.length),
  25. changes: { from: range.from, to: range.to, insert: text },
  26. }
  27. }
  28. return {
  29. range: EditorSelection.cursor(range.from + latex.length),
  30. changes: { from: range.from, to: range.to, insert: latex },
  31. }
  32. })
  33. export const storePastedContent = (
  34. content: PastedContent,
  35. formatted: boolean
  36. ) => ({
  37. effects: pastedContentEffect.of({ content, formatted }),
  38. })
  39. const pastedContentTheme = EditorView.baseTheme({
  40. '.ol-cm-pasted-content-menu-toggle': {
  41. background: 'none',
  42. borderRadius: '8px',
  43. border: '1px solid rgb(125, 125, 125)',
  44. margin: '0 4px',
  45. opacity: '0.7',
  46. '&:hover': {
  47. opacity: '1',
  48. },
  49. },
  50. '.ol-cm-pasted-content-menu-popover': {
  51. maxWidth: 'unset',
  52. '& .popover-content': {
  53. padding: 0,
  54. },
  55. },
  56. '&dark .ol-cm-pasted-content-menu-popover': {
  57. background: 'rgba(0, 0, 0)',
  58. },
  59. '.ol-cm-pasted-content-menu': {
  60. display: 'flex',
  61. flexDirection: 'column',
  62. boxSizing: 'border-box',
  63. fontSize: '14px',
  64. fontFamily: '"Lato", sans-serif',
  65. },
  66. '.ol-cm-pasted-content-menu-item': {
  67. border: 'none',
  68. background: 'none',
  69. padding: '8px 16px',
  70. width: '100%',
  71. display: 'flex',
  72. justifyContent: 'space-between',
  73. alignItems: 'center',
  74. whiteSpace: 'nowrap',
  75. gap: '12px',
  76. '&[aria-disabled="true"]': {
  77. color: 'rgba(125, 125, 125, 0.5)',
  78. },
  79. '&:hover': {
  80. backgroundColor: 'rgba(125, 125, 125, 0.2)',
  81. },
  82. },
  83. '.ol-cm-pasted-content-menu-item-label': {
  84. flex: 1,
  85. textAlign: 'left',
  86. },
  87. '.ol-cm-pasted-content-menu-item-shortcut': {
  88. textAlign: 'right',
  89. },
  90. })
  91. export const pastedContent = StateField.define<{
  92. content: PastedContent
  93. formatted: boolean
  94. selection: EditorSelection
  95. } | null>({
  96. create() {
  97. return null
  98. },
  99. update(value, tr) {
  100. if (tr.docChanged) {
  101. // TODO: exclude remote changes (if they don't intersect with changed ranges)?
  102. value = null
  103. } else {
  104. for (const effect of tr.effects) {
  105. if (effect.is(pastedContentEffect)) {
  106. value = {
  107. ...effect.value,
  108. selection: tr.state.selection,
  109. }
  110. }
  111. }
  112. }
  113. return value
  114. },
  115. provide(field) {
  116. return [
  117. EditorView.decorations.compute([field], state => {
  118. const value = state.field(field)
  119. if (!value) {
  120. return Decoration.none
  121. }
  122. const decorations: Range<Decoration>[] = []
  123. const { content, selection, formatted } = value
  124. decorations.push(
  125. Decoration.widget({
  126. widget: new PastedContentMenuWidget(content, formatted),
  127. side: 1,
  128. }).range(selection.main.to)
  129. )
  130. return Decoration.set(decorations, true)
  131. }),
  132. pastedContentTheme,
  133. ]
  134. },
  135. })
  136. class PastedContentMenuWidget extends WidgetType {
  137. constructor(
  138. private pastedContent: PastedContent,
  139. private formatted: boolean
  140. ) {
  141. super()
  142. }
  143. toDOM(view: EditorView) {
  144. const element = document.createElement('span')
  145. ReactDOM.render(
  146. <SplitTestProvider>
  147. <PastedContentMenu
  148. insertPastedContent={this.insertPastedContent}
  149. view={view}
  150. formatted={this.formatted}
  151. pastedContent={this.pastedContent}
  152. />
  153. </SplitTestProvider>,
  154. element
  155. )
  156. return element
  157. }
  158. insertPastedContent(
  159. view: EditorView,
  160. pastedContent: PastedContent,
  161. formatted: boolean
  162. ) {
  163. undo(view)
  164. view.dispatch(
  165. insertPastedContent(view, {
  166. latex: formatted ? pastedContent.latex : pastedContent.text,
  167. text: pastedContent.text,
  168. })
  169. )
  170. view.dispatch(storePastedContent(pastedContent, formatted))
  171. view.focus()
  172. }
  173. eq(widget: PastedContentMenuWidget) {
  174. return (
  175. widget.pastedContent === this.pastedContent &&
  176. widget.formatted === this.formatted
  177. )
  178. }
  179. }