| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- import { expect } from 'chai'
- import { EditorState, EditorSelection } from '@codemirror/state'
- import { EditorView } from '@codemirror/view'
- import {
- copySelection,
- cutSelection,
- pasteWithoutFormatting,
- pasteWithFormatting,
- } from '../../../../../frontend/js/features/source-editor/commands/clipboard'
- const createClipboardStub = () => {
- const stub = {
- written: null as string | null,
- reads: [] as string[],
- rejectRead: undefined as Error | undefined,
- writeText: async (text: string) => {
- stub.written = text
- },
- readText: async () => {
- if (stub.rejectRead) throw stub.rejectRead
- return stub.reads.shift() ?? ''
- },
- }
- return stub
- }
- const createView = (doc: string, anchor = 0, head = anchor) => {
- const state = EditorState.create({ doc, selection: { anchor, head } })
- const parent = document.createElement('div')
- return new EditorView({ state, parent })
- }
- const createViewWithMultipleRanges = (
- doc: string,
- ranges: Array<{ anchor: number; head?: number }>
- ) => {
- const state = EditorState.create({
- doc,
- extensions: [EditorState.allowMultipleSelections.of(true)],
- selection: EditorSelection.create(
- ranges.map(({ anchor, head }) =>
- EditorSelection.range(anchor, head ?? anchor)
- )
- ),
- })
- const parent = document.createElement('div')
- return new EditorView({ state, parent })
- }
- describe('clipboard behavior', function () {
- let clipboard: ReturnType<typeof createClipboardStub>
- beforeEach(function () {
- clipboard = createClipboardStub()
- ;(navigator as any).clipboard = clipboard
- })
- describe('copySelection', function () {
- it('copies only the selected range when a selection exists', async function () {
- const view = createView('abcde', 1, 4) // selects "bcd"
- await copySelection(view)
- expect(clipboard.written).to.equal('bcd')
- })
- it('copies entire current line with trailing break when no selection (middle line)', async function () {
- const view = createView('one\ntwo\nthree', 5) // inside "two"
- await copySelection(view)
- expect(clipboard.written).to.equal('two\n')
- })
- it('copies last line without an extra trailing break at document end', async function () {
- const view = createView('one\ntwo\nthree', 9) // inside "three"
- await copySelection(view)
- expect(clipboard.written).to.equal('three')
- })
- it('copies all selected ranges when multiple selections exist', async function () {
- const view = createViewWithMultipleRanges('abcdefgh', [
- { anchor: 0, head: 1 }, // "a"
- { anchor: 3, head: 5 }, // "de"
- ])
- await copySelection(view)
- expect(clipboard.written).to.equal('ade')
- })
- it('copies entire lines for each cursor when multiple empty selections', async function () {
- const view = createViewWithMultipleRanges('line1\nline2\nline3', [
- { anchor: 2 }, // in "line1"
- { anchor: 8 }, // in "line2"
- ])
- await copySelection(view)
- expect(clipboard.written).to.equal('line1\nline2\n')
- })
- })
- describe('cutSelection', function () {
- it('cuts only the selected range when a selection exists', async function () {
- const view = createView('abcde', 1, 4) // selects "bcd"
- await cutSelection(view)
- expect(clipboard.written).to.equal('bcd')
- expect(view.state.doc.toString()).to.equal('ae')
- })
- it('cuts entire line with trailing break when no selection (middle line)', async function () {
- const view = createView('first\nsecond', 1)
- await cutSelection(view)
- expect(clipboard.written).to.equal('first\n')
- expect(view.state.doc.toString()).to.equal('second')
- })
- it('cuts last line without removing the preceding newline when at document end', async function () {
- const view = createView('first\nsecond', 8) // inside last line
- await cutSelection(view)
- expect(clipboard.written).to.equal('second')
- expect(view.state.doc.toString()).to.equal('first\n')
- })
- it('cuts all selected ranges when multiple selections exist', async function () {
- const view = createViewWithMultipleRanges('abcdefgh', [
- { anchor: 0, head: 2 }, // "ab"
- { anchor: 4, head: 6 }, // "ef"
- ])
- await cutSelection(view)
- expect(clipboard.written).to.equal('abef')
- expect(view.state.doc.toString()).to.equal('cdgh')
- })
- it('cuts entire lines for each cursor when multiple empty selections', async function () {
- const view = createViewWithMultipleRanges('line1\nline2\nline3', [
- { anchor: 2 }, // in "line1"
- { anchor: 14 }, // in "line3"
- ])
- await cutSelection(view)
- expect(clipboard.written).to.equal('line1\nline3')
- expect(view.state.doc.toString()).to.equal('line2\n')
- })
- })
- describe('pasteWithoutFormatting', function () {
- it('inserts a line-wise single line above current line when no selection', async function () {
- clipboard.reads.push('pasted\n')
- const view = createView('current', 0)
- await pasteWithoutFormatting(view)
- expect(view.state.doc.toString()).to.equal('pasted\ncurrent')
- })
- it('replaces an existing selection inline with single-line text', async function () {
- clipboard.reads.push('XX')
- const view = createView('hello', 1, 3) // replace "el"
- await pasteWithoutFormatting(view)
- expect(view.state.doc.toString()).to.equal('hXXlo')
- })
- it('replaces an existing selection even when clipboard text is line-wise (no insert-above)', async function () {
- clipboard.reads.push('line\n')
- const view = createView('abc', 0, 1) // replace "a"
- await pasteWithoutFormatting(view)
- expect(view.state.doc.toString()).to.equal('line\nbc')
- })
- it('pastes multiline text inline (no line-wise handling)', async function () {
- clipboard.reads.push('lineA\nlineB\n')
- const view = createView('X', 0)
- await pasteWithoutFormatting(view)
- expect(view.state.doc.toString()).to.equal('lineA\nlineB\nX')
- })
- it('returns false and leaves document unchanged if clipboard read fails', async function () {
- clipboard.rejectRead = new Error('denied')
- const view = createView('stay', 0)
- const result = await pasteWithoutFormatting(view)
- expect(result).to.equal(false)
- expect(view.state.doc.toString()).to.equal('stay')
- })
- it('pastes into all selected ranges when multiple selections exist', async function () {
- clipboard.reads.push('XX')
- const view = createViewWithMultipleRanges('abcdefgh', [
- { anchor: 1, head: 2 }, // "b"
- { anchor: 5, head: 6 }, // "f"
- ])
- await pasteWithoutFormatting(view)
- expect(view.state.doc.toString()).to.equal('aXXcdeXXgh')
- })
- it('pastes line-wise content at line start for each cursor when multiple empty selections', async function () {
- clipboard.reads.push('new\n')
- const view = createViewWithMultipleRanges('line1\nline2', [
- { anchor: 2 }, // in "line1"
- { anchor: 8 }, // in "line2"
- ])
- await pasteWithoutFormatting(view)
- expect(view.state.doc.toString()).to.equal('new\nline1\nnew\nline2')
- })
- it('preserves multiple cursors after pasting into multiple selections', async function () {
- clipboard.reads.push('XX')
- const view = createViewWithMultipleRanges('abcdefgh', [
- { anchor: 1, head: 2 }, // "b"
- { anchor: 5, head: 6 }, // "f"
- ])
- await pasteWithoutFormatting(view)
- const ranges = view.state.selection.ranges
- expect(ranges).to.have.length(2)
- expect(ranges.every(r => r.empty)).to.equal(true)
- })
- it('preserves multiple cursors when pasting line-wise content', async function () {
- clipboard.reads.push('new\n')
- const view = createViewWithMultipleRanges('line1\nline2', [
- { anchor: 2 }, // in "line1"
- { anchor: 8 }, // in "line2"
- ])
- await pasteWithoutFormatting(view)
- // After line-wise paste at line starts: cursor should be after each "new\n"
- const ranges = view.state.selection.ranges
- expect(ranges).to.have.length(2)
- expect(ranges[0].anchor).to.equal(4) // after first "new\n"
- expect(ranges[1].anchor).to.equal(14) // after second "new\n" (original pos 8 + 4 from first insert + 4 from second)
- })
- })
- describe('pasteWithFormatting', function () {
- // Helper to set clipboard.read with HTML + plain text
- const setClipboardHtml = (html: string, text: string) => {
- ;(navigator as any).clipboard.read = async () => [
- {
- types: ['text/html', 'text/plain'],
- // Avoid relying on DOM Blob in the Mocha/jsdom environment.
- getType: async (type: string) =>
- ({
- text: async () => (type === 'text/html' ? html : text),
- }) as any,
- },
- ]
- ;(navigator as any).clipboard.readText = async () => text
- }
- it('pastes converted HTML into a single selection', async function () {
- setClipboardHtml('<b>x</b>', 'x')
- const view = createView('abcde', 2, 3) // replace 'c'
- await pasteWithFormatting(view)
- expect(view.state.doc.toString()).to.equal('ab\\textbf{x}de')
- expect(view.state.selection.ranges).to.have.length(1)
- expect(view.state.selection.main.empty).to.equal(true)
- })
- it('pastes converted HTML into all selected ranges (multi-range)', async function () {
- setClipboardHtml('<strong>x</strong>', 'x')
- const view = createViewWithMultipleRanges('abcdefgh', [
- { anchor: 1, head: 2 }, // b
- { anchor: 5, head: 6 }, // f
- ])
- await pasteWithFormatting(view)
- expect(view.state.doc.toString()).to.equal('a\\textbf{x}cde\\textbf{x}gh')
- expect(view.state.selection.ranges).to.have.length(2)
- expect(view.state.selection.ranges.every(r => r.empty)).to.equal(true)
- })
- it('falls back to plain text when there is no formatting', async function () {
- setClipboardHtml('x', 'x')
- const view = createViewWithMultipleRanges('abcdefgh', [
- { anchor: 1, head: 2 },
- { anchor: 5, head: 6 },
- ])
- await pasteWithFormatting(view)
- expect(view.state.doc.toString()).to.equal('axcdexgh')
- expect(view.state.selection.ranges).to.have.length(2)
- })
- })
- })
|