pasted-content.tsx 5.2 KB

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