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) {