clipboard.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. import { EditorView } from '@codemirror/view'
  2. import { EditorSelection } from '@codemirror/state'
  3. import { handleImagePaste } from '../utils/paste-image'
  4. import { convertHtmlStringToLatex } from '../extensions/visual/paste-html'
  5. import {
  6. insertPastedContent,
  7. storePastedContent,
  8. } from '../extensions/visual/pasted-content'
  9. const getEntireLineText = (view: EditorView, pos: number): string => {
  10. const line = view.state.doc.lineAt(pos)
  11. const atDocumentEnd = line.to === view.state.doc.length
  12. return atDocumentEnd ? line.text : line.text + view.state.lineBreak
  13. }
  14. const pastePlainText = (view: EditorView, text: string): void => {
  15. // Detect line-wise paste: single line of text with trailing linebreak
  16. const textWithoutTrailingBreak = text.slice(0, -view.state.lineBreak.length)
  17. const isLineWise =
  18. text.endsWith(view.state.lineBreak) &&
  19. !textWithoutTrailingBreak.includes(view.state.lineBreak)
  20. // Use changeByRange to apply paste to each selection/range
  21. const changes = view.state.changeByRange(range => {
  22. const { from, to } = range
  23. const noSelection = from === to
  24. const shouldInsertAtLineStart = noSelection && isLineWise
  25. if (shouldInsertAtLineStart) {
  26. const line = view.state.doc.lineAt(from)
  27. return {
  28. changes: { from: line.from, to: line.from, insert: text },
  29. range: EditorSelection.cursor(line.from + text.length),
  30. }
  31. }
  32. return {
  33. changes: { from, to, insert: text },
  34. range: EditorSelection.cursor(from + text.length),
  35. }
  36. })
  37. view.dispatch(changes)
  38. }
  39. export const cutSelection = async (view: EditorView): Promise<boolean> => {
  40. const selections = view.state.selection.ranges
  41. const changes = []
  42. const texts = []
  43. for (const range of selections) {
  44. const { from, to } = range
  45. if (from === to) {
  46. const text = getEntireLineText(view, from)
  47. texts.push(text)
  48. const line = view.state.doc.lineAt(from)
  49. const atDocumentEnd = line.to === view.state.doc.length
  50. const deleteTo = atDocumentEnd
  51. ? line.to
  52. : line.to + view.state.lineBreak.length
  53. changes.push({ from: line.from, to: deleteTo, insert: '' })
  54. } else {
  55. const text = view.state.sliceDoc(from, to)
  56. texts.push(text)
  57. changes.push({ from, to, insert: '' })
  58. }
  59. }
  60. await navigator.clipboard.writeText(texts.join(''))
  61. view.dispatch({
  62. changes,
  63. selection: { anchor: changes[0]?.from ?? view.state.selection.main.from },
  64. })
  65. return true
  66. }
  67. export const copySelection = async (view: EditorView): Promise<boolean> => {
  68. const selections = view.state.selection.ranges
  69. const texts = []
  70. for (const range of selections) {
  71. const { from, to } = range
  72. const text =
  73. from === to
  74. ? getEntireLineText(view, from)
  75. : view.state.sliceDoc(from, to)
  76. texts.push(text)
  77. }
  78. await navigator.clipboard.writeText(texts.join(''))
  79. return true
  80. }
  81. export const pasteWithoutFormatting = async (
  82. view: EditorView
  83. ): Promise<boolean> => {
  84. // Check for pasted images first
  85. if (await handleImagePaste()) {
  86. return true
  87. }
  88. // Fall back to plain text paste
  89. try {
  90. const text = await navigator.clipboard.readText()
  91. pastePlainText(view, text)
  92. return true
  93. } catch {
  94. // Clipboard access denied
  95. return false
  96. }
  97. }
  98. export const pasteWithFormatting = async (
  99. view: EditorView
  100. ): Promise<boolean> => {
  101. try {
  102. const clipboardItems = await navigator.clipboard.read()
  103. let html = ''
  104. let text = ''
  105. let nonTextBlobCount = 0
  106. for (const item of clipboardItems) {
  107. for (const type of item.types) {
  108. const blob = await item.getType(type)
  109. if (type === 'text/html') {
  110. html = (await blob.text()).trim()
  111. } else if (type === 'text/plain') {
  112. text = (await blob.text()).trim()
  113. } else if (!type.startsWith('text/')) {
  114. nonTextBlobCount++
  115. }
  116. }
  117. }
  118. if (!html) {
  119. return await pasteWithoutFormatting(view)
  120. }
  121. const latex = convertHtmlStringToLatex(html, nonTextBlobCount)
  122. if (latex === null || (latex === text && nonTextBlobCount === 0)) {
  123. // No latex or formatting detected, use plain text paste
  124. return await pasteWithoutFormatting(view)
  125. }
  126. view.dispatch(insertPastedContent(view, { latex, text }))
  127. view.dispatch(storePastedContent({ latex, text }, true))
  128. return true
  129. } catch {
  130. // Clipboard.read not available, or latex conversion failed, use standard paste behavior
  131. return await pasteWithoutFormatting(view)
  132. }
  133. }