clipboard.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import { expect } from 'chai'
  2. import { EditorState, EditorSelection } from '@codemirror/state'
  3. import { EditorView } from '@codemirror/view'
  4. import {
  5. copySelection,
  6. cutSelection,
  7. pasteWithoutFormatting,
  8. pasteWithFormatting,
  9. } from '../../../../../frontend/js/features/source-editor/commands/clipboard'
  10. const createClipboardStub = () => {
  11. const stub = {
  12. written: null as string | null,
  13. reads: [] as string[],
  14. rejectRead: undefined as Error | undefined,
  15. writeText: async (text: string) => {
  16. stub.written = text
  17. },
  18. readText: async () => {
  19. if (stub.rejectRead) throw stub.rejectRead
  20. return stub.reads.shift() ?? ''
  21. },
  22. }
  23. return stub
  24. }
  25. const createView = (doc: string, anchor = 0, head = anchor) => {
  26. const state = EditorState.create({ doc, selection: { anchor, head } })
  27. const parent = document.createElement('div')
  28. return new EditorView({ state, parent })
  29. }
  30. const createViewWithMultipleRanges = (
  31. doc: string,
  32. ranges: Array<{ anchor: number; head?: number }>
  33. ) => {
  34. const state = EditorState.create({
  35. doc,
  36. extensions: [EditorState.allowMultipleSelections.of(true)],
  37. selection: EditorSelection.create(
  38. ranges.map(({ anchor, head }) =>
  39. EditorSelection.range(anchor, head ?? anchor)
  40. )
  41. ),
  42. })
  43. const parent = document.createElement('div')
  44. return new EditorView({ state, parent })
  45. }
  46. describe('clipboard behavior', function () {
  47. let clipboard: ReturnType<typeof createClipboardStub>
  48. beforeEach(function () {
  49. clipboard = createClipboardStub()
  50. ;(navigator as any).clipboard = clipboard
  51. })
  52. describe('copySelection', function () {
  53. it('copies only the selected range when a selection exists', async function () {
  54. const view = createView('abcde', 1, 4) // selects "bcd"
  55. await copySelection(view)
  56. expect(clipboard.written).to.equal('bcd')
  57. })
  58. it('copies entire current line with trailing break when no selection (middle line)', async function () {
  59. const view = createView('one\ntwo\nthree', 5) // inside "two"
  60. await copySelection(view)
  61. expect(clipboard.written).to.equal('two\n')
  62. })
  63. it('copies last line without an extra trailing break at document end', async function () {
  64. const view = createView('one\ntwo\nthree', 9) // inside "three"
  65. await copySelection(view)
  66. expect(clipboard.written).to.equal('three')
  67. })
  68. it('copies all selected ranges when multiple selections exist', async function () {
  69. const view = createViewWithMultipleRanges('abcdefgh', [
  70. { anchor: 0, head: 1 }, // "a"
  71. { anchor: 3, head: 5 }, // "de"
  72. ])
  73. await copySelection(view)
  74. expect(clipboard.written).to.equal('ade')
  75. })
  76. it('copies entire lines for each cursor when multiple empty selections', async function () {
  77. const view = createViewWithMultipleRanges('line1\nline2\nline3', [
  78. { anchor: 2 }, // in "line1"
  79. { anchor: 8 }, // in "line2"
  80. ])
  81. await copySelection(view)
  82. expect(clipboard.written).to.equal('line1\nline2\n')
  83. })
  84. })
  85. describe('cutSelection', function () {
  86. it('cuts only the selected range when a selection exists', async function () {
  87. const view = createView('abcde', 1, 4) // selects "bcd"
  88. await cutSelection(view)
  89. expect(clipboard.written).to.equal('bcd')
  90. expect(view.state.doc.toString()).to.equal('ae')
  91. })
  92. it('cuts entire line with trailing break when no selection (middle line)', async function () {
  93. const view = createView('first\nsecond', 1)
  94. await cutSelection(view)
  95. expect(clipboard.written).to.equal('first\n')
  96. expect(view.state.doc.toString()).to.equal('second')
  97. })
  98. it('cuts last line without removing the preceding newline when at document end', async function () {
  99. const view = createView('first\nsecond', 8) // inside last line
  100. await cutSelection(view)
  101. expect(clipboard.written).to.equal('second')
  102. expect(view.state.doc.toString()).to.equal('first\n')
  103. })
  104. it('cuts all selected ranges when multiple selections exist', async function () {
  105. const view = createViewWithMultipleRanges('abcdefgh', [
  106. { anchor: 0, head: 2 }, // "ab"
  107. { anchor: 4, head: 6 }, // "ef"
  108. ])
  109. await cutSelection(view)
  110. expect(clipboard.written).to.equal('abef')
  111. expect(view.state.doc.toString()).to.equal('cdgh')
  112. })
  113. it('cuts entire lines for each cursor when multiple empty selections', async function () {
  114. const view = createViewWithMultipleRanges('line1\nline2\nline3', [
  115. { anchor: 2 }, // in "line1"
  116. { anchor: 14 }, // in "line3"
  117. ])
  118. await cutSelection(view)
  119. expect(clipboard.written).to.equal('line1\nline3')
  120. expect(view.state.doc.toString()).to.equal('line2\n')
  121. })
  122. })
  123. describe('pasteWithoutFormatting', function () {
  124. it('inserts a line-wise single line above current line when no selection', async function () {
  125. clipboard.reads.push('pasted\n')
  126. const view = createView('current', 0)
  127. await pasteWithoutFormatting(view)
  128. expect(view.state.doc.toString()).to.equal('pasted\ncurrent')
  129. })
  130. it('replaces an existing selection inline with single-line text', async function () {
  131. clipboard.reads.push('XX')
  132. const view = createView('hello', 1, 3) // replace "el"
  133. await pasteWithoutFormatting(view)
  134. expect(view.state.doc.toString()).to.equal('hXXlo')
  135. })
  136. it('replaces an existing selection even when clipboard text is line-wise (no insert-above)', async function () {
  137. clipboard.reads.push('line\n')
  138. const view = createView('abc', 0, 1) // replace "a"
  139. await pasteWithoutFormatting(view)
  140. expect(view.state.doc.toString()).to.equal('line\nbc')
  141. })
  142. it('pastes multiline text inline (no line-wise handling)', async function () {
  143. clipboard.reads.push('lineA\nlineB\n')
  144. const view = createView('X', 0)
  145. await pasteWithoutFormatting(view)
  146. expect(view.state.doc.toString()).to.equal('lineA\nlineB\nX')
  147. })
  148. it('returns false and leaves document unchanged if clipboard read fails', async function () {
  149. clipboard.rejectRead = new Error('denied')
  150. const view = createView('stay', 0)
  151. const result = await pasteWithoutFormatting(view)
  152. expect(result).to.equal(false)
  153. expect(view.state.doc.toString()).to.equal('stay')
  154. })
  155. it('pastes into all selected ranges when multiple selections exist', async function () {
  156. clipboard.reads.push('XX')
  157. const view = createViewWithMultipleRanges('abcdefgh', [
  158. { anchor: 1, head: 2 }, // "b"
  159. { anchor: 5, head: 6 }, // "f"
  160. ])
  161. await pasteWithoutFormatting(view)
  162. expect(view.state.doc.toString()).to.equal('aXXcdeXXgh')
  163. })
  164. it('pastes line-wise content at line start for each cursor when multiple empty selections', async function () {
  165. clipboard.reads.push('new\n')
  166. const view = createViewWithMultipleRanges('line1\nline2', [
  167. { anchor: 2 }, // in "line1"
  168. { anchor: 8 }, // in "line2"
  169. ])
  170. await pasteWithoutFormatting(view)
  171. expect(view.state.doc.toString()).to.equal('new\nline1\nnew\nline2')
  172. })
  173. it('preserves multiple cursors after pasting into multiple selections', async function () {
  174. clipboard.reads.push('XX')
  175. const view = createViewWithMultipleRanges('abcdefgh', [
  176. { anchor: 1, head: 2 }, // "b"
  177. { anchor: 5, head: 6 }, // "f"
  178. ])
  179. await pasteWithoutFormatting(view)
  180. const ranges = view.state.selection.ranges
  181. expect(ranges).to.have.length(2)
  182. expect(ranges.every(r => r.empty)).to.equal(true)
  183. })
  184. it('preserves multiple cursors when pasting line-wise content', async function () {
  185. clipboard.reads.push('new\n')
  186. const view = createViewWithMultipleRanges('line1\nline2', [
  187. { anchor: 2 }, // in "line1"
  188. { anchor: 8 }, // in "line2"
  189. ])
  190. await pasteWithoutFormatting(view)
  191. // After line-wise paste at line starts: cursor should be after each "new\n"
  192. const ranges = view.state.selection.ranges
  193. expect(ranges).to.have.length(2)
  194. expect(ranges[0].anchor).to.equal(4) // after first "new\n"
  195. expect(ranges[1].anchor).to.equal(14) // after second "new\n" (original pos 8 + 4 from first insert + 4 from second)
  196. })
  197. })
  198. describe('pasteWithFormatting', function () {
  199. // Helper to set clipboard.read with HTML + plain text
  200. const setClipboardHtml = (html: string, text: string) => {
  201. ;(navigator as any).clipboard.read = async () => [
  202. {
  203. types: ['text/html', 'text/plain'],
  204. // Avoid relying on DOM Blob in the Mocha/jsdom environment.
  205. getType: async (type: string) =>
  206. ({
  207. text: async () => (type === 'text/html' ? html : text),
  208. }) as any,
  209. },
  210. ]
  211. ;(navigator as any).clipboard.readText = async () => text
  212. }
  213. it('pastes converted HTML into a single selection', async function () {
  214. setClipboardHtml('<b>x</b>', 'x')
  215. const view = createView('abcde', 2, 3) // replace 'c'
  216. await pasteWithFormatting(view)
  217. expect(view.state.doc.toString()).to.equal('ab\\textbf{x}de')
  218. expect(view.state.selection.ranges).to.have.length(1)
  219. expect(view.state.selection.main.empty).to.equal(true)
  220. })
  221. it('pastes converted HTML into all selected ranges (multi-range)', async function () {
  222. setClipboardHtml('<strong>x</strong>', 'x')
  223. const view = createViewWithMultipleRanges('abcdefgh', [
  224. { anchor: 1, head: 2 }, // b
  225. { anchor: 5, head: 6 }, // f
  226. ])
  227. await pasteWithFormatting(view)
  228. expect(view.state.doc.toString()).to.equal('a\\textbf{x}cde\\textbf{x}gh')
  229. expect(view.state.selection.ranges).to.have.length(2)
  230. expect(view.state.selection.ranges.every(r => r.empty)).to.equal(true)
  231. })
  232. it('falls back to plain text when there is no formatting', async function () {
  233. setClipboardHtml('x', 'x')
  234. const view = createViewWithMultipleRanges('abcdefgh', [
  235. { anchor: 1, head: 2 },
  236. { anchor: 5, head: 6 },
  237. ])
  238. await pasteWithFormatting(view)
  239. expect(view.state.doc.toString()).to.equal('axcdexgh')
  240. expect(view.state.selection.ranges).to.have.length(2)
  241. })
  242. })
  243. })