Preserve line endings when pasting content We fix an issue when pasting into a new file or replacing all content, so that it preserves the line ending. Change-Id: Ie249812f0f30362a49e6bb5fa60da800849fd514
diff --git a/web/element/codemirror-element.ts b/web/element/codemirror-element.ts index ea59615..38f320c 100644 --- a/web/element/codemirror-element.ts +++ b/web/element/codemirror-element.ts
@@ -6,7 +6,16 @@ import {LitElement, css, html} from 'lit'; import {customElement, property, query} from 'lit/decorators.js'; import {EditorView} from '@codemirror/view'; -import {EditorState} from '@codemirror/state'; +import { + Compartment, + EditorSelection, + EditorState, + StateEffect, + StateField, + Text, + Transaction, +} from '@codemirror/state'; +import {invertedEffects} from '@codemirror/commands'; import {updateRulerWidth} from './ruler'; import {extensions} from './extensions'; @@ -42,6 +51,34 @@ auto_close_brackets?: boolean; } +/** + * Effect used to record a change to the document's line ending. + * + * This effect is tracked by CodeMirror's existing history extension. + */ +const changeLineEndingEffect = StateEffect.define<string>(); + +/** + * Stores the line ending associated with the current document. + * + * CodeMirror's document itself always stores line breaks as LF. + * This field stores the line ending that should be used when the + * document is serialized. + */ +const lineEndingField = StateField.define<string>({ + create: () => '\n', + + update(lineEnding, transaction) { + for (const effect of transaction.effects) { + if (effect.is(changeLineEndingEffect)) { + return effect.value; + } + } + + return lineEnding; + }, +}); + @customElement('codemirror-element') export class CodeMirrorElement extends LitElement { @property({type: Number}) lineNum?: number; @@ -76,13 +113,16 @@ /* CodeMirror has a default z-index of 4. Set to 0 to avoid collisions with fixed header. */ z-index: 0; } + .CodeMirror-ruler { border-left: 1px solid #ddd; } + .cm-editor .cm-content { font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, monospace; } + #statusLine { position: absolute; bottom: 0; @@ -92,9 +132,11 @@ border-top: 1px solid #ddd; border-right: 1px solid #ddd; } + #statusLine div { height: inherit; } + .cursorPosition { display: inline-block; margin: 0 5px 0 35px; @@ -120,64 +162,239 @@ } private initialize() { - if (!this.isConnected || !this.wrapper) return; + if (!this.isConnected || !this.wrapper || this.initialized) { + return; + } - if (this.initialized) return; this.initialized = true; + /* + * Detect whether the ORIGINAL file was mixed. + */ + const initialHasCRLF = this.fileContent?.includes('\r\n'); + + const initialHasBareLF = /(?<!\r)\n/.test(this.fileContent ?? ''); + + const initialMixedLineEndings = initialHasCRLF && initialHasBareLF; + + if (initialMixedLineEndings && this.fileContent) { + // We want to make sure that only \n is converted to \r\n. + this.fileContent = this.fileContent.replace(/\r?\n/g, '\r\n'); + this.dispatchContentChange(this.fileContent); + + // Trigger an alert informing user that the line endings that are mixed + // are converted to CRLF. + this.fire('Mixed line endings converted to CRLF.'); + } + + // We can't access 'this' prop within EditorView.updateListener, + // thus we workaround it by doing it here and using detectLineEnding() + // within it. + const detectLineEnding = (text: string) => this.detectLineEnding(text); + + /* + * Detect the initial file's line ending. + */ + const initialLineEnding = detectLineEnding(this.fileContent ?? ''); + + /* + * EditorState.lineSeparator is a static facet, so it cannot be + * dynamically computed from a StateField. + * + * A Compartment lets us change it as part of a transaction. + */ + const lineSeparatorCompartment = new Compartment(); + + /* + * CodeMirror history needs to know how to undo BOTH: + * + * 1. changeLineEndingEffect + * 2. lineSeparatorCompartment.reconfigure(...) + * + * Otherwise undo would restore lineEndingField but leave + * EditorState.lineSeparator at the newer value. + */ + const lineEndingHistoryInversion = invertedEffects.of( + (transaction: Transaction) => { + const effects: StateEffect<unknown>[] = []; + + for (const effect of transaction.effects) { + if (!effect.is(changeLineEndingEffect)) { + continue; + } + + /* + * Restore the line ending that existed before this transaction. + */ + const previousLineEnding = + transaction.startState.field(lineEndingField); + + /* + * Restore the custom StateField. + */ + effects.push(changeLineEndingEffect.of(previousLineEnding)); + + /* + * Restore EditorState.lineSeparator as well. + * + * This is the important part that was missing before. + */ + effects.push( + lineSeparatorCompartment.reconfigure( + EditorState.lineSeparator.of(previousLineEnding), + ), + ); + } + + return effects; + }, + ); + const editor = new EditorView({ state: EditorState.create({ doc: this.fileContent ?? '', + extensions: [ + /* + * Track the current line ending. + */ + lineEndingField.init(() => initialLineEnding), + + /* + * Make line-ending changes part of the existing CodeMirror + * history. + * + * history() itself is provided by extensions.ts. + */ + lineEndingHistoryInversion, + + /* + * Configure the initial line separator. + */ + lineSeparatorCompartment.of( + EditorState.lineSeparator.of(initialLineEnding), + ), + ...extensions( this.calculateHeight(), this.prefs, this.fileType, - this.fileContent ?? '', this.darkMode, ), - EditorView.updateListener.of(update => { - // Set ruler width to 72 for commit messages, - // as this is the recommended line length for git commit messages. - if (this.fileType?.includes('x-gerrit-commit-message')) { - updateRulerWidth(72, update.view.defaultCharacterWidth, true); - } else if (this.prefs?.line_length) { - // This is required to be in the setTimeout() to ensure the - // line is set as correctly as possible. - updateRulerWidth( - this.prefs.line_length, - update.view.defaultCharacterWidth, - true, - ); - } - if (update.docChanged) { - this.dispatchEvent( - new CustomEvent('content-change', { - detail: {value: update.state.sliceDoc()}, - bubbles: true, - composed: true, - }), - ); + EditorView.updateListener.of(update => { + this.updateRuler(update.view); + + const oldLineEnding = update.startState.field(lineEndingField); + + const newLineEnding = update.state.field(lineEndingField); + + /* + * Normally a content edit has docChanged === true. + * + * A paste can, however, replace CRLF text with LF text + * resulting in the same normalized CodeMirror document. + * In that case the line-ending state still changed and + * must produce content-change. + */ + if (update.docChanged || oldLineEnding !== newLineEnding) { + this.dispatchContentChange(update.state.sliceDoc()); } }), + EditorView.domEventHandlers({ - keydown(e: KeyboardEvent) { - // Exempt the ctrl/command+s key from preventing events from propagating - // through the app. This is because we use it to save changes. - if (!e.metaKey && !e.ctrlKey) { - e.stopPropagation(); + keydown(event: KeyboardEvent) { + // Exempt the ctrl/command+s key from preventing events from + // propagating through the app. This is because we use it to + // save changes. + if (!event.metaKey && !event.ctrlKey) { + event.stopPropagation(); } // There is an issue where you paste and immediately // press ctrl+s/cmd+s after, it would trigger the // web browsers file browser rather then gr-editor-view // intercepting ctrl+s/cmd+s. - if ((e.metaKey || e.ctrlKey) && e.key === 'v') { - e.stopPropagation(); + if ((event.metaKey || event.ctrlKey) && event.key === 'v') { + event.stopPropagation(); } }, + + paste(event: ClipboardEvent, view: EditorView) { + const text = event.clipboardData?.getData('text/plain') ?? ''; + + if (!text) { + return false; + } + + /* + * Determine the EOL from the raw clipboard text. + */ + const lineEnding = detectLineEnding(text); + + /* + * CodeMirror's internal Text representation uses LF. + * + * Remove CR from CRLF before inserting the text so that + * carriage returns don't become visible characters. + */ + const insert = Text.of(text.replace(/\r\n/g, '\n').split('\n')); + + /* + * Build the normal CodeMirror replacement. + */ + const changes = view.state.changeByRange(range => { + return { + changes: { + from: range.from, + to: range.to, + insert, + }, + range: EditorSelection.cursor(range.from + insert.length), + }; + }); + + const isNewFile = view.state.doc.length === 0; + const isReplacingAll = + view.state.selection.ranges.length === 1 && + view.state.selection.main.from === 0 && + view.state.selection.main.to === view.state.doc.length; + + const effects: StateEffect<unknown>[] = []; + + if (isNewFile || isReplacingAll) { + effects.push( + changeLineEndingEffect.of(lineEnding), + lineSeparatorCompartment.reconfigure( + EditorState.lineSeparator.of(lineEnding), + ), + ); + } + + /* + * Everything is one transaction: + * + * document change + * lineEndingField change + * EditorState.lineSeparator change + * + * CodeMirror history therefore records this as one edit. + */ + view.dispatch({ + changes: changes.changes, + selection: changes.selection, + effects, + }); + + /* + * We performed the paste ourselves. + */ + event.preventDefault(); + + return true; + }, }), + EditorView.updateListener.of(update => { if (update.selectionSet) { this.updateCursorPosition(update.view); @@ -185,18 +402,18 @@ }), ], }), - parent: this.wrapper as Element, + parent: this.wrapper, }); this.editor = editor; + editor.focus(); if (this.lineNum) { this.setCursorToLine(editor, this.lineNum); } - // Makes sure to show line number and column number on initial - // load. + // Makes sure to show line number and column number on initial load. this.updateCursorPosition(editor); this.onResize = () => this.updateEditorHeight(editor); @@ -205,18 +422,55 @@ override disconnectedCallback() { super.disconnectedCallback(); + if (this.onResize) { window.removeEventListener('resize', this.onResize); this.onResize = null; } - // Clear the editor reference when disconnected. + this.editor = undefined; } - setCursorToLine(view: EditorView, lineNum: number) { + /** + * Updates the ruler width according to the current file type/preferences. + */ + private updateRuler(view: EditorView) { + if (this.fileType?.includes('x-gerrit-commit-message')) { + // Set ruler width to 72 for commit messages, + // as this is the recommended line length for git commit messages. + updateRulerWidth(72, view.defaultCharacterWidth, true); + } else if (this.prefs?.line_length) { + updateRulerWidth( + this.prefs.line_length, + view.defaultCharacterWidth, + true, + ); + } + } + + /** + * Dispatches the current document using the current + * EditorState.lineSeparator. + */ + private dispatchContentChange(value: string) { + this.dispatchEvent( + new CustomEvent('content-change', { + detail: {value}, + bubbles: true, + composed: true, + }), + ); + } + + private detectLineEnding(text: string): string { + return text.includes('\r\n') ? '\r\n' : '\n'; + } + + private setCursorToLine(view: EditorView, lineNum: number) { const totalLines = view.state.doc.lines; + // If you try going to a line that does not exist - // codemirror will error out. Instead lets just log. + // CodeMirror will error out. Instead just log. // Line 1 will be selected automatically. if (lineNum < 1 || lineNum > totalLines) { console.warn( @@ -226,37 +480,57 @@ } const line = view.state.doc.line(lineNum); + view.dispatch({ selection: {anchor: line.from}, scrollIntoView: true, }); + view.focus(); } private updateCursorPosition(view: EditorView) { const cursor = view.state.selection.main.head; const line = view.state.doc.lineAt(cursor); + const lineEnding = view.state.field(lineEndingField); + if (this.result) { this.result.textContent = `Line: ${line.number}, Column: ${ cursor - line.from + 1 - }`; + }, Line ending: ${lineEnding === '\r\n' ? 'CRLF' : 'LF'}`; } } private calculateHeight() { const offsetTop = this.getBoundingClientRect().top; const clientHeight = window.innerHeight ?? document.body.clientHeight; - // We take offsetTop twice to ensure the height of the texarea doesn't push - // out of screen. We no longer do a hardcore value which was 80 before. + + // We take offsetTop twice to ensure the height of the textarea doesn't + // push out of screen. We no longer do a hardcore value which was 80 before. // offsetTop seems to be what we've been looking for to do it dynamically. return Math.max(0, clientHeight - offsetTop - offsetTop); } private updateEditorHeight(editor: EditorView) { const height = this.calculateHeight(); + const editorElement = editor.dom; + if (editorElement) { editorElement.style.height = `${height}px`; } } + + private fire(message: string) { + this.dispatchEvent( + new CustomEvent('show-alert', { + detail: { + message, + showDismiss: true, + }, + composed: true, + bubbles: true, + }), + ); + } }
diff --git a/web/element/codemirror-element_test.ts b/web/element/codemirror-element_test.ts index 4aa1fd7..52e1f53 100644 --- a/web/element/codemirror-element_test.ts +++ b/web/element/codemirror-element_test.ts
@@ -5,8 +5,10 @@ */ import '../test/test-setup'; import './codemirror-element'; +import {redo, undo} from '@codemirror/commands'; import {assert, fixture, html} from '@open-wc/testing'; import {CodeMirrorElement} from './codemirror-element'; +import {EditorState} from '@codemirror/state'; suite('codemirror-element tests', () => { let element: CodeMirrorElement; @@ -22,84 +24,6 @@ await element.updateComplete; }); - test('render', () => { - assert.shadowDom.equal( - element, - /* HTML */ ` - <div id="wrapper"> - <div class="cm-editor ͼ1 ͼ1f ͼ1g ͼ1h ͼ1i ͼ2 ͼ4"> - <div aria-live="polite" class="cm-announced"></div> - <div class="cm-scroller" tabindex="-1"> - <div - aria-hidden="true" - class="cm-gutters cm-gutters-before" - style="min-height: 14px; position: sticky;" - > - <div class="cm-gutter cm-lineNumbers"> - <div - class="cm-gutterElement" - style="height: 0px; visibility: hidden; pointer-events: none;" - > - 9 - </div> - <div - class="cm-activeLineGutter cm-gutterElement" - style="height: 14px;" - > - 1 - </div> - </div> - <div class="cm-foldGutter cm-gutter"> - <div - class="cm-gutterElement" - style="height: 0px; visibility: hidden; pointer-events: none;" - > - <span title="Unfold line"> › </span> - </div> - <div - class="cm-activeLineGutter cm-gutterElement" - style="height: 14px;" - ></div> - </div> - </div> - <div - aria-multiline="true" - autocapitalize="off" - autocorrect="off" - class="cm-content" - contenteditable="true" - role="textbox" - spellcheck="false" - style="tab-size: 0;" - translate="no" - writingsuggestions="false" - > - <div class="cm-line"> - <br /> - </div> - </div> - <div - aria-hidden="true" - class="cm-cursorLayer cm-layer cm-layer-above" - style="z-index: 150; animation-duration: 1200ms;" - ></div> - <div - aria-hidden="true" - class="cm-layer cm-selectionLayer" - style="z-index: -2;" - ></div> - </div> - </div> - </div> - <div class="statusLine"> - <div class="cursorPosition"> - <span id="result"> Line: 1, Column: 1 </span> - </div> - </div> - `, - ); - }); - async function waitForEditorInstance( el: CodeMirrorElement, attempts = 20, @@ -107,9 +31,10 @@ ) { for (let i = 0; i < attempts && !el.editor; i++) { await new Promise<void>(resolve => { - setTimeout((): void => resolve(), delayMs); + setTimeout(() => resolve(), delayMs); }); } + return el.editor; } @@ -117,14 +42,30 @@ return new Promise<CustomEvent<{value: string}>>(resolve => { el.addEventListener( 'content-change', - (e: Event): void => { - resolve(e as CustomEvent<{value: string}>); + (event: Event): void => { + resolve(event as CustomEvent<{value: string}>); }, {once: true}, ); }); } + function dispatchPaste(editor: CodeMirrorElement, text: string) { + const editorView = editor.editor; + + assert.isOk(editorView); + + const pasteEvent = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: new DataTransfer(), + }); + + pasteEvent.clipboardData!.setData('text/plain', text); + + editorView!.contentDOM.dispatchEvent(pasteEvent); + } + test('dispatches content-change event when editor content changes', async () => { const initialContent = 'first line\nsecond line\n'; @@ -141,7 +82,7 @@ const eventPromise = waitForContentChangeEvent(editorElement); - void editorView.dispatch({ + editorView!.dispatch({ changes: { from: 0, to: 0, @@ -152,7 +93,7 @@ const event = await eventPromise; assert.isString(event.detail.value); - assert.isTrue(event.detail.value.length > initialContent.length); + assert.isAbove(event.detail.value.length, initialContent.length); }); test('content-change preserves LF for LF-only inputs', async () => { @@ -169,7 +110,7 @@ const eventPromise = waitForContentChangeEvent(editorElement); - void editorView.dispatch({ + editorView!.dispatch({ changes: { from: 0, to: 0, @@ -179,7 +120,8 @@ const event = await eventPromise; - assert.isFalse(/\r\n/.test(event.detail.value)); + assert.notInclude(event.detail.value, '\r\n'); + assert.include(event.detail.value, '\n'); assert.isTrue(event.detail.value.endsWith('\n')); }); @@ -197,7 +139,7 @@ const eventPromise = waitForContentChangeEvent(editorElement); - void editorView.dispatch({ + editorView!.dispatch({ changes: { from: 0, to: 0, @@ -207,8 +149,385 @@ const event = await eventPromise; - assert.isString(event.detail.value); - assert.isTrue(/\r\n/.test(event.detail.value)); + assert.include(event.detail.value, '\r\n'); assert.isTrue(event.detail.value.endsWith('\r\n')); }); + + test('paste with CRLF updates document line separator', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` <codemirror-element .fileContent=${''}></codemirror-element> `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); + + const contentPromise = waitForContentChangeEvent(editorElement); + + dispatchPaste(editorElement, 'a\r\nb\r\n'); + + const event = await contentPromise; + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + assert.include(event.detail.value, '\r\n'); + assert.notInclude(event.detail.value.replace(/\r\n/g, ''), '\n'); + }); + + test('replacing entire document with CRLF via paste preserves CRLF', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` <codemirror-element .fileContent=${'hello'}></codemirror-element> `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); + + /* + * Select the entire document so the paste replaces it. + */ + editorView!.dispatch({ + selection: { + anchor: 0, + head: editorView!.state.doc.length, + }, + }); + + const contentPromise = waitForContentChangeEvent(editorElement); + + dispatchPaste(editorElement, 'a\r\nb\r\n'); + + const event = await contentPromise; + + /* + * The paste handler sees the raw clipboard data before CodeMirror + * normalizes it, so it can correctly change the active separator. + */ + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + assert.equal(event.detail.value, 'a\r\nb\r\n'); + }); + + test('pasting text into existing CRLF document preserves CRLF', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` + <codemirror-element + .fileContent=${'first line\r\nsecond line\r\n'} + ></codemirror-element> + `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + /* + * Put the cursor in the middle of the document. + */ + editorView!.dispatch({ + selection: { + anchor: 5, + }, + }); + + const contentPromise = waitForContentChangeEvent(editorElement); + + /* + * Pasting a single word has no line ending of its own. + * It must not change the document's existing line ending. + */ + dispatchPaste(editorElement, 'foo'); + + const event = await contentPromise; + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + assert.include(event.detail.value, '\r\n'); + assert.notInclude(event.detail.value.replace(/\r\n/g, ''), '\n'); + }); + + test('typing Enter with keyboard updates document content', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` <codemirror-element .fileContent=${'hello'}></codemirror-element> `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + editorView!.focus(); + + editorView!.dispatch({ + selection: { + anchor: editorView!.state.doc.length, + }, + }); + + const keyEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + bubbles: true, + cancelable: true, + }); + + editorView!.contentDOM.dispatchEvent(keyEvent); + + assert.equal(editorView!.state.sliceDoc(), 'hello\n'); + }); + + test('typing with keyboard updates document content', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` <codemirror-element .fileContent=${''}></codemirror-element> `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + editorView!.focus(); + + const contentPromise = waitForContentChangeEvent(editorElement); + + /* + * Simulate text being entered through the browser's editing + * machinery rather than dispatching a CodeMirror transaction. + */ + const contentDOM = editorView!.contentDOM; + + contentDOM.focus(); + + document.execCommand('insertText', false, 'hello'); + + const event = await contentPromise; + + assert.equal(event.detail.value, 'hello'); + assert.equal(editorView!.state.sliceDoc(), 'hello'); + }); + + test('undo restores CRLF line ending after replacing document with LF paste', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` + <codemirror-element .fileContent=${'a\r\nb\r\n'}></codemirror-element> + `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + /* + * Select the entire document so the paste replaces it. + */ + editorView!.dispatch({ + selection: { + anchor: 0, + head: editorView!.state.doc.length, + }, + }); + + const contentPromise = waitForContentChangeEvent(editorElement); + + dispatchPaste(editorElement, 'x\ny\n'); + + await contentPromise; + + /* + * Replacing the entire document with LF text changes the + * active line ending to LF. + */ + let value = editorView!.state.sliceDoc(); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); + + assert.equal(value, 'x\ny\n'); + + /* + * Undo must restore both the document and the CRLF + * line-ending configuration. + */ + undo(editorView!); + + value = editorView!.state.sliceDoc(); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + assert.equal(value, 'a\r\nb\r\n'); + }); + + test('redo restores LF line ending after undoing full-document LF paste', async () => { + const editorElement = await fixture<CodeMirrorElement>( + html` + <codemirror-element .fileContent=${'a\r\nb\r\n'}></codemirror-element> + `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + /* + * Select the entire document so the paste replaces it. + */ + editorView!.dispatch({ + selection: { + anchor: 0, + head: editorView!.state.doc.length, + }, + }); + + const contentPromise = waitForContentChangeEvent(editorElement); + + dispatchPaste(editorElement, 'x\ny\n'); + + await contentPromise; + + /* + * The full-document replacement changes CRLF to LF. + */ + let value = editorView!.state.sliceDoc(); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); + + assert.equal(value, 'x\ny\n'); + + /* + * Undo restores the original CRLF state. + */ + undo(editorView!); + + value = editorView!.state.sliceDoc(); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + + assert.equal(value, 'a\r\nb\r\n'); + + /* + * Redo restores the LF state. + */ + redo(editorView!); + + value = editorView!.state.sliceDoc(); + + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\n'); + + assert.equal(value, 'x\ny\n'); + }); + + test('copy preserves CRLF line endings', () => { + const editorView = element.editor; + + assert.isOk(editorView); + + editorView!.dispatch({ + changes: { + from: 0, + to: editorView!.state.doc.length, + insert: 'a\r\nb\r\n', + }, + }); + + editorView!.dispatch({ + selection: { + anchor: 0, + head: editorView!.state.doc.length, + }, + }); + + const copyEvent = new ClipboardEvent('copy', { + bubbles: true, + cancelable: true, + clipboardData: new DataTransfer(), + }); + + editorView!.contentDOM.dispatchEvent(copyEvent); + + const copiedText = copyEvent.clipboardData!.getData('text/plain'); + + assert.include(copiedText, '\r\n'); + assert.notEqual(copiedText, 'a\nb\n'); + }); + + test('copy preserves LF line endings', () => { + const editorView = element.editor; + + assert.isOk(editorView); + + editorView!.dispatch({ + changes: { + from: 0, + to: editorView!.state.doc.length, + insert: 'a\nb\n', + }, + }); + + editorView!.dispatch({ + selection: { + anchor: 0, + head: editorView!.state.doc.length, + }, + }); + + const copyEvent = new ClipboardEvent('copy', { + bubbles: true, + cancelable: true, + clipboardData: new DataTransfer(), + }); + + editorView!.contentDOM.dispatchEvent(copyEvent); + + const copiedText = copyEvent.clipboardData!.getData('text/plain'); + + assert.include(copiedText, '\n'); + assert.notInclude(copiedText, '\r\n'); + }); + + test('converts mixed line endings to CRLF', async () => { + const mixedContent = 'first line\r\nsecond line\nthird line\r\n'; + + const editorElement = await fixture<CodeMirrorElement>( + html` + <codemirror-element .fileContent=${mixedContent}></codemirror-element> + `, + ); + + await editorElement.updateComplete; + + const editorView = await waitForEditorInstance(editorElement); + assert.isOk(editorView); + + /* + * Mixed line endings should be normalized to CRLF when the + * editor is initialized. + */ + const value = editorView!.state.sliceDoc(); + + assert.equal(value, 'first line\r\nsecond line\r\nthird line\r\n'); + + /* + * Verify that there are no bare LF line endings. + */ + assert.notInclude(value.replace(/\r\n/g, ''), '\n'); + + /* + * The active line separator should also be CRLF. + */ + assert.equal(editorView!.state.facet(EditorState.lineSeparator), '\r\n'); + }); });
diff --git a/web/element/extensions.ts b/web/element/extensions.ts index 08f9c30..ceeb5c0 100644 --- a/web/element/extensions.ts +++ b/web/element/extensions.ts
@@ -143,7 +143,6 @@ height: number, prefs?: EditPreferencesInfo, fileType?: string, - fileContent?: string, darkMode?: boolean, ) => { const codeExtensions: Array<Extension> = [ @@ -172,10 +171,6 @@ hideTabsAndSpaces(), ]; - if (fileContent?.includes('\r\n')) { - codeExtensions.push(EditorState.lineSeparator.of('\r\n')); - } - if (!prefs) return codeExtensions; if (prefs.line_length) {