blob: 38f320cab6c3906717b1d638baa5baa3a2d883d3 [file]
/**
* @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,
}),
);
}
}