| /** | |
| * @license | |
| * Copyright 2022 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import {LitElement, css, html} from 'lit'; | |
| import {customElement, property, query} from 'lit/decorators.js'; | |
| import {EditorView} from '@codemirror/view'; | |
| import { | |
| Compartment, | |
| EditorSelection, | |
| EditorState, | |
| StateEffect, | |
| StateField, | |
| Text, | |
| Transaction, | |
| } from '@codemirror/state'; | |
| import {invertedEffects} from '@codemirror/commands'; | |
| import {updateRulerWidth} from './ruler'; | |
| import {extensions} from './extensions'; | |
| type ValueChangedEvent<T = string> = CustomEvent<{value: T}>; | |
| declare global { | |
| interface HTMLElementEventMap { | |
| 'content-change': ValueChangedEvent; | |
| } | |
| interface HTMLElementTagNameMap { | |
| // @ts-ignore TS2717: Subsequent property declarations must have the same | |
| // type. | |
| 'codemirror-element': CodeMirrorElement; | |
| } | |
| } | |
| /** | |
| * This is a standard REST API object: | |
| * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#edit-preferences-info | |
| * | |
| * TODO: Add this object to the plugin API. | |
| */ | |
| export interface EditPreferencesInfo { | |
| tab_size?: number; | |
| line_length?: number; | |
| indent_unit?: number; | |
| show_tabs?: boolean; | |
| show_whitespace_errors?: boolean; | |
| syntax_highlighting?: boolean; | |
| match_brackets?: boolean; | |
| line_wrapping?: boolean; | |
| indent_with_tabs?: boolean; | |
| 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; | |
| @property({type: String}) fileContent?: string; | |
| @property({type: String}) fileType?: string; | |
| @property({type: Object}) prefs?: EditPreferencesInfo; | |
| @property({type: Boolean}) darkMode = false; | |
| @query('#wrapper') | |
| wrapper!: HTMLElement; | |
| @query('#result') | |
| result!: HTMLElement; | |
| // Expose the editor instance so tests can dispatch editor changes. | |
| editor?: EditorView; | |
| private initialized = false; | |
| private onResize: (() => void) | null = null; | |
| static override get styles() { | |
| return [ | |
| css` | |
| .cm-editor { | |
| font-family: 'Roboto Mono', 'SF Mono', 'Lucida Console', Monaco, | |
| monospace; | |
| /* 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; | |
| left: 0; | |
| width: 100%; | |
| background-color: #f7f7f7; | |
| border-top: 1px solid #ddd; | |
| border-right: 1px solid #ddd; | |
| } | |
| #statusLine div { | |
| height: inherit; | |
| } | |
| .cursorPosition { | |
| display: inline-block; | |
| margin: 0 5px 0 35px; | |
| white-space: nowrap; | |
| } | |
| `, | |
| ]; | |
| } | |
| override render() { | |
| return html` | |
| <div id="wrapper"></div> | |
| <div class="statusLine"> | |
| <div class="cursorPosition"> | |
| <span id="result"></span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| override updated() { | |
| this.initialize(); | |
| } | |
| private initialize() { | |
| if (!this.isConnected || !this.wrapper || 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.darkMode, | |
| ), | |
| 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(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 ((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); | |
| } | |
| }), | |
| ], | |
| }), | |
| 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. | |
| this.updateCursorPosition(editor); | |
| this.onResize = () => this.updateEditorHeight(editor); | |
| window.addEventListener('resize', this.onResize); | |
| } | |
| override disconnectedCallback() { | |
| super.disconnectedCallback(); | |
| if (this.onResize) { | |
| window.removeEventListener('resize', this.onResize); | |
| this.onResize = null; | |
| } | |
| this.editor = undefined; | |
| } | |
| /** | |
| * 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 just log. | |
| // Line 1 will be selected automatically. | |
| if (lineNum < 1 || lineNum > totalLines) { | |
| console.warn( | |
| `Line number ${lineNum} is out of bounds (valid range: 1 - ${totalLines}).`, | |
| ); | |
| return; | |
| } | |
| 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 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, | |
| }), | |
| ); | |
| } | |
| } |