// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
///
///
import CodeMirror from 'codemirror';
import { JSONExt } from '@phosphor/coreutils';
import { ArrayExt } from '@phosphor/algorithm';
import { IDisposable, DisposableDelegate } from '@phosphor/disposable';
import { Signal } from '@phosphor/signaling';
import { showDialog } from '@jupyterlab/apputils';
import { Poll } from '@jupyterlab/coreutils';
import { CodeEditor } from '@jupyterlab/codeeditor';
import { UUID } from '@phosphor/coreutils';
import {
IObservableMap,
IObservableString,
ICollaborator
} from '@jupyterlab/observables';
import { Mode } from './mode';
import 'codemirror/addon/comment/comment.js';
import 'codemirror/addon/display/rulers.js';
import 'codemirror/addon/edit/matchbrackets.js';
import 'codemirror/addon/edit/closebrackets.js';
import 'codemirror/addon/fold/foldcode.js';
import 'codemirror/addon/fold/foldgutter.js';
import 'codemirror/addon/fold/brace-fold.js';
import 'codemirror/addon/fold/indent-fold.js';
import 'codemirror/addon/fold/markdown-fold.js';
import 'codemirror/addon/fold/xml-fold.js';
import 'codemirror/addon/fold/comment-fold.js';
import 'codemirror/addon/scroll/scrollpastend.js';
import 'codemirror/addon/search/searchcursor';
import 'codemirror/addon/search/search';
import 'codemirror/addon/search/jump-to-line';
import 'codemirror/addon/selection/active-line';
import 'codemirror/addon/selection/mark-selection';
import 'codemirror/addon/selection/selection-pointer';
import 'codemirror/keymap/emacs.js';
import 'codemirror/keymap/sublime.js';
// import 'codemirror/keymap/vim.js'; lazy loading of vim mode is available in ../codemirror-extension/index.ts
/**
* The class name added to CodeMirrorWidget instances.
*/
const EDITOR_CLASS = 'jp-CodeMirrorEditor';
/**
* The class name added to read only cell editor widgets.
*/
const READ_ONLY_CLASS = 'jp-mod-readOnly';
/**
* The class name for the hover box for collaborator cursors.
*/
const COLLABORATOR_CURSOR_CLASS = 'jp-CollaboratorCursor';
/**
* The class name for the hover box for collaborator cursors.
*/
const COLLABORATOR_HOVER_CLASS = 'jp-CollaboratorCursor-hover';
/**
* The key code for the up arrow key.
*/
const UP_ARROW = 38;
/**
* The key code for the down arrow key.
*/
const DOWN_ARROW = 40;
/**
* The time that a collaborator name hover persists.
*/
const HOVER_TIMEOUT = 1000;
/**
* CodeMirror editor.
*/
export class CodeMirrorEditor implements CodeEditor.IEditor {
/**
* Construct a CodeMirror editor.
*/
constructor(options: CodeMirrorEditor.IOptions) {
let host = (this.host = options.host);
host.classList.add(EDITOR_CLASS);
host.classList.add('jp-Editor');
host.addEventListener('focus', this, true);
host.addEventListener('blur', this, true);
host.addEventListener('scroll', this, true);
this._uuid = options.uuid || UUID.uuid4();
// Handle selection style.
let style = options.selectionStyle || {};
this._selectionStyle = {
...CodeEditor.defaultSelectionStyle,
...(style as CodeEditor.ISelectionStyle)
};
let model = (this._model = options.model);
let config = options.config || {};
let fullConfig = (this._config = {
...CodeMirrorEditor.defaultConfig,
...config
});
let editor = (this._editor = Private.createEditor(host, fullConfig));
let doc = editor.getDoc();
// Handle initial values for text, mimetype, and selections.
doc.setValue(model.value.text);
this.clearHistory();
this._onMimeTypeChanged();
this._onCursorActivity();
this._poll = new Poll({
factory: async () => {
this._checkSync();
},
frequency: { interval: 3000, backoff: false },
standby: () => {
// If changed, only stand by when hidden, otherwise always stand by.
return this._lastChange ? 'when-hidden' : true;
}
});
// Connect to changes.
model.value.changed.connect(this._onValueChanged, this);
model.mimeTypeChanged.connect(this._onMimeTypeChanged, this);
model.selections.changed.connect(this._onSelectionsChanged, this);
CodeMirror.on(editor, 'keydown', (editor: CodeMirror.Editor, event) => {
let index = ArrayExt.findFirstIndex(this._keydownHandlers, handler => {
if (handler(this, event) === true) {
event.preventDefault();
return true;
}
return false;
});
if (index === -1) {
this.onKeydown(event);
}
});
CodeMirror.on(editor, 'cursorActivity', () => this._onCursorActivity());
CodeMirror.on(editor.getDoc(), 'beforeChange', (instance, change) => {
this._beforeDocChanged(instance, change);
});
CodeMirror.on(editor.getDoc(), 'change', (instance, change) => {
// Manually refresh after setValue to make sure editor is properly sized.
if (change.origin === 'setValue' && this.hasFocus()) {
this.refresh();
}
this._lastChange = change;
});
// Manually refresh on paste to make sure editor is properly sized.
editor.getWrapperElement().addEventListener('paste', () => {
if (this.hasFocus()) {
this.refresh();
}
});
}
/**
* A signal emitted when either the top or bottom edge is requested.
*/
readonly edgeRequested = new Signal(this);
/**
* The DOM node that hosts the editor.
*/
readonly host: HTMLElement;
/**
* The uuid of this editor;
*/
get uuid(): string {
return this._uuid;
}
set uuid(value: string) {
this._uuid = value;
}
/**
* The selection style of this editor.
*/
get selectionStyle(): CodeEditor.ISelectionStyle {
return this._selectionStyle;
}
set selectionStyle(value: CodeEditor.ISelectionStyle) {
this._selectionStyle = value;
}
/**
* Get the codemirror editor wrapped by the editor.
*/
get editor(): CodeMirror.Editor {
return this._editor;
}
/**
* Get the codemirror doc wrapped by the widget.
*/
get doc(): CodeMirror.Doc {
return this._editor.getDoc();
}
/**
* Get the number of lines in the editor.
*/
get lineCount(): number {
return this.doc.lineCount();
}
/**
* Returns a model for this editor.
*/
get model(): CodeEditor.IModel {
return this._model;
}
/**
* The height of a line in the editor in pixels.
*/
get lineHeight(): number {
return this._editor.defaultTextHeight();
}
/**
* The widget of a character in the editor in pixels.
*/
get charWidth(): number {
return this._editor.defaultCharWidth();
}
/**
* Tests whether the editor is disposed.
*/
get isDisposed(): boolean {
return this._isDisposed;
}
/**
* Dispose of the resources held by the widget.
*/
dispose(): void {
if (this.isDisposed) {
return;
}
this._isDisposed = true;
this.host.removeEventListener('focus', this, true);
this.host.removeEventListener('blur', this, true);
this.host.removeEventListener('scroll', this, true);
this._keydownHandlers.length = 0;
this._poll.dispose();
Signal.clearData(this);
}
/**
* Get a config option for the editor.
*/
getOption(
option: K
): CodeMirrorEditor.IConfig[K] {
return this._config[option];
}
/**
* Set a config option for the editor.
*/
setOption(
option: K,
value: CodeMirrorEditor.IConfig[K]
): void {
// Don't bother setting the option if it is already the same.
if (this._config[option] !== value) {
this._config[option] = value;
Private.setOption(this.editor, option, value, this._config);
}
}
/**
* Returns the content for the given line number.
*/
getLine(line: number): string | undefined {
return this.doc.getLine(line);
}
/**
* Find an offset for the given position.
*/
getOffsetAt(position: CodeEditor.IPosition): number {
return this.doc.indexFromPos({
ch: position.column,
line: position.line
});
}
/**
* Find a position for the given offset.
*/
getPositionAt(offset: number): CodeEditor.IPosition {
const { ch, line } = this.doc.posFromIndex(offset);
return { line, column: ch };
}
/**
* Undo one edit (if any undo events are stored).
*/
undo(): void {
this.doc.undo();
}
/**
* Redo one undone edit.
*/
redo(): void {
this.doc.redo();
}
/**
* Clear the undo history.
*/
clearHistory(): void {
this.doc.clearHistory();
}
/**
* Brings browser focus to this editor text.
*/
focus(): void {
this._editor.focus();
}
/**
* Test whether the editor has keyboard focus.
*/
hasFocus(): boolean {
return this._editor.getWrapperElement().contains(document.activeElement);
}
/**
* Explicitly blur the editor.
*/
blur(): void {
this._editor.getInputField().blur();
}
/**
* Repaint editor.
*/
refresh(): void {
this._editor.refresh();
this._needsRefresh = false;
}
/**
* Refresh the editor if it is focused;
* otherwise postpone refreshing till focusing.
*/
resizeToFit(): void {
if (this.hasFocus()) {
this.refresh();
} else {
this._needsRefresh = true;
}
this._clearHover();
}
// todo: docs, maybe define overlay options as a type?
addOverlay(mode: string | object, options?: object): void {
this._editor.addOverlay(mode, options);
}
removeOverlay(mode: string | object): void {
this._editor.removeOverlay(mode);
}
getSearchCursor(
query: string | RegExp,
start?: CodeMirror.Position,
caseFold?: boolean
): CodeMirror.SearchCursor {
return this._editor.getDoc().getSearchCursor(query, start, caseFold);
}
getCursor(start?: string): CodeMirror.Position {
return this._editor.getDoc().getCursor(start);
}
get state(): any {
return this._editor.state;
}
operation(fn: () => T): T {
return this._editor.operation(fn);
}
firstLine(): number {
return this._editor.getDoc().firstLine();
}
lastLine(): number {
return this._editor.getDoc().lastLine();
}
scrollIntoView(
pos: { from: CodeMirror.Position; to: CodeMirror.Position },
margin: number
): void {
this._editor.scrollIntoView(pos, margin);
}
cursorCoords(
where: boolean,
mode?: 'window' | 'page' | 'local'
): { left: number; top: number; bottom: number } {
return this._editor.cursorCoords(where, mode);
}
getRange(
from: CodeMirror.Position,
to: CodeMirror.Position,
seperator?: string
): string {
return this._editor.getDoc().getRange(from, to, seperator);
}
/**
* Add a keydown handler to the editor.
*
* @param handler - A keydown handler.
*
* @returns A disposable that can be used to remove the handler.
*/
addKeydownHandler(handler: CodeEditor.KeydownHandler): IDisposable {
this._keydownHandlers.push(handler);
return new DisposableDelegate(() => {
ArrayExt.removeAllWhere(this._keydownHandlers, val => val === handler);
});
}
/**
* Set the size of the editor in pixels.
*/
setSize(dimension: CodeEditor.IDimension | null): void {
if (dimension) {
this._editor.setSize(dimension.width, dimension.height);
} else {
this._editor.setSize(null, null);
}
this._needsRefresh = false;
}
/**
* Reveal the given position in the editor.
*/
revealPosition(position: CodeEditor.IPosition): void {
const cmPosition = this._toCodeMirrorPosition(position);
this._editor.scrollIntoView(cmPosition);
}
/**
* Reveal the given selection in the editor.
*/
revealSelection(selection: CodeEditor.IRange): void {
const range = {
from: this._toCodeMirrorPosition(selection.start),
to: this._toCodeMirrorPosition(selection.end)
};
this._editor.scrollIntoView(range);
}
/**
* Get the window coordinates given a cursor position.
*/
getCoordinateForPosition(
position: CodeEditor.IPosition
): CodeEditor.ICoordinate {
const pos = this._toCodeMirrorPosition(position);
const rect = this.editor.charCoords(pos, 'page');
return rect as CodeEditor.ICoordinate;
}
/**
* Get the cursor position given window coordinates.
*
* @param coordinate - The desired coordinate.
*
* @returns The position of the coordinates, or null if not
* contained in the editor.
*/
getPositionForCoordinate(
coordinate: CodeEditor.ICoordinate
): CodeEditor.IPosition | null {
return this._toPosition(this.editor.coordsChar(coordinate)) || null;
}
/**
* Returns the primary position of the cursor, never `null`.
*/
getCursorPosition(): CodeEditor.IPosition {
const cursor = this.doc.getCursor();
return this._toPosition(cursor);
}
/**
* Set the primary position of the cursor.
*
* #### Notes
* This will remove any secondary cursors.
*/
setCursorPosition(position: CodeEditor.IPosition): void {
const cursor = this._toCodeMirrorPosition(position);
this.doc.setCursor(cursor);
// If the editor does not have focus, this cursor change
// will get screened out in _onCursorsChanged(). Make an
// exception for this method.
if (!this.editor.hasFocus()) {
this.model.selections.set(this.uuid, this.getSelections());
}
}
/**
* Returns the primary selection, never `null`.
*/
getSelection(): CodeEditor.ITextSelection {
return this.getSelections()[0];
}
/**
* Set the primary selection. This will remove any secondary cursors.
*/
setSelection(selection: CodeEditor.IRange): void {
this.setSelections([selection]);
}
/**
* Gets the selections for all the cursors, never `null` or empty.
*/
getSelections(): CodeEditor.ITextSelection[] {
const selections = this.doc.listSelections();
if (selections.length > 0) {
return selections.map(selection => this._toSelection(selection));
}
const cursor = this.doc.getCursor();
const selection = this._toSelection({ anchor: cursor, head: cursor });
return [selection];
}
/**
* Sets the selections for all the cursors, should not be empty.
* Cursors will be removed or added, as necessary.
* Passing an empty array resets a cursor position to the start of a document.
*/
setSelections(selections: CodeEditor.IRange[]): void {
const cmSelections = this._toCodeMirrorSelections(selections);
this.doc.setSelections(cmSelections, 0);
}
/**
* Get a list of tokens for the current editor text content.
*/
getTokens(): CodeEditor.IToken[] {
let tokens: CodeEditor.IToken[] = [];
for (let i = 0; i < this.lineCount; ++i) {
const lineTokens = this.editor.getLineTokens(i).map(t => ({
offset: this.getOffsetAt({ column: t.start, line: i }),
value: t.string,
type: t.type || ''
}));
tokens = tokens.concat(lineTokens);
}
return tokens;
}
/**
* Get the token at a given editor position.
*/
getTokenForPosition(position: CodeEditor.IPosition): CodeEditor.IToken {
const cursor = this._toCodeMirrorPosition(position);
const token = this.editor.getTokenAt(cursor);
return {
offset: this.getOffsetAt({ column: token.start, line: cursor.line }),
value: token.string,
type: token.type
};
}
/**
* Insert a new indented line at the current cursor position.
*/
newIndentedLine(): void {
this.execCommand('newlineAndIndent');
}
/**
* Execute a codemirror command on the editor.
*
* @param command - The name of the command to execute.
*/
execCommand(command: string): void {
this._editor.execCommand(command);
}
/**
* Handle keydown events from the editor.
*/
protected onKeydown(event: KeyboardEvent): boolean {
let position = this.getCursorPosition();
let { line, column } = position;
if (line === 0 && column === 0 && event.keyCode === UP_ARROW) {
if (!event.shiftKey) {
this.edgeRequested.emit('top');
}
return false;
}
if (line === 0 && event.keyCode === UP_ARROW) {
if (!event.shiftKey) {
this.edgeRequested.emit('topLine');
}
return false;
}
let lastLine = this.lineCount - 1;
let lastCh = this.getLine(lastLine)!.length;
if (
line === lastLine &&
column === lastCh &&
event.keyCode === DOWN_ARROW
) {
if (!event.shiftKey) {
this.edgeRequested.emit('bottom');
}
return false;
}
return false;
}
/**
* Converts selections to code mirror selections.
*/
private _toCodeMirrorSelections(
selections: CodeEditor.IRange[]
): CodeMirror.Selection[] {
if (selections.length > 0) {
return selections.map(selection =>
this._toCodeMirrorSelection(selection)
);
}
const position = { line: 0, ch: 0 };
return [{ anchor: position, head: position }];
}
/**
* Handles a mime type change.
*/
private _onMimeTypeChanged(): void {
const mime = this._model.mimeType;
let editor = this._editor;
// TODO: should we provide a hook for when the
// mode is done being set?
void Mode.ensure(mime).then(spec => {
editor.setOption('mode', spec.mime);
});
let extraKeys = editor.getOption('extraKeys') || {};
const isCode = mime !== 'text/plain' && mime !== 'text/x-ipythongfm';
if (isCode) {
extraKeys['Backspace'] = 'delSpaceToPrevTabStop';
} else {
delete extraKeys['Backspace'];
}
editor.setOption('extraKeys', extraKeys);
}
/**
* Handles a selections change.
*/
private _onSelectionsChanged(
selections: IObservableMap,
args: IObservableMap.IChangedArgs
): void {
const uuid = args.key;
if (uuid !== this.uuid) {
this._cleanSelections(uuid);
if (args.type !== 'remove' && args.newValue) {
this._markSelections(uuid, args.newValue);
}
}
}
/**
* Clean selections for the given uuid.
*/
private _cleanSelections(uuid: string) {
const markers = this.selectionMarkers[uuid];
if (markers) {
markers.forEach(marker => {
marker.clear();
});
}
delete this.selectionMarkers[uuid];
}
/**
* Marks selections.
*/
private _markSelections(
uuid: string,
selections: CodeEditor.ITextSelection[]
) {
const markers: CodeMirror.TextMarker[] = [];
// If we are marking selections corresponding to an active hover,
// remove it.
if (uuid === this._hoverId) {
this._clearHover();
}
// If we can id the selection to a specific collaborator,
// use that information.
let collaborator: ICollaborator | undefined;
if (this._model.modelDB.collaborators) {
collaborator = this._model.modelDB.collaborators.get(uuid);
}
// Style each selection for the uuid.
selections.forEach(selection => {
// Only render selections if the start is not equal to the end.
// In that case, we don't need to render the cursor.
if (!JSONExt.deepEqual(selection.start, selection.end)) {
// Selections only appear to render correctly if the anchor
// is before the head in the document. That is, reverse selections
// do not appear as intended.
let forward: boolean =
selection.start.line < selection.end.line ||
(selection.start.line === selection.end.line &&
selection.start.column <= selection.end.column);
let anchor = this._toCodeMirrorPosition(
forward ? selection.start : selection.end
);
let head = this._toCodeMirrorPosition(
forward ? selection.end : selection.start
);
let markerOptions: CodeMirror.TextMarkerOptions;
if (collaborator) {
markerOptions = this._toTextMarkerOptions({
...selection.style,
color: collaborator.color
});
} else {
markerOptions = this._toTextMarkerOptions(selection.style);
}
markers.push(this.doc.markText(anchor, head, markerOptions));
} else if (collaborator) {
let caret = this._getCaret(collaborator);
markers.push(
this.doc.setBookmark(this._toCodeMirrorPosition(selection.end), {
widget: caret
})
);
}
});
this.selectionMarkers[uuid] = markers;
}
/**
* Handles a cursor activity event.
*/
private _onCursorActivity(): void {
// Only add selections if the editor has focus. This avoids unwanted
// triggering of cursor activity due to collaborator actions.
if (this._editor.hasFocus()) {
const selections = this.getSelections();
this.model.selections.set(this.uuid, selections);
}
}
/**
* Converts a code mirror selection to an editor selection.
*/
private _toSelection(
selection: CodeMirror.Selection
): CodeEditor.ITextSelection {
return {
uuid: this.uuid,
start: this._toPosition(selection.anchor),
end: this._toPosition(selection.head),
style: this.selectionStyle
};
}
/**
* Converts the selection style to a text marker options.
*/
private _toTextMarkerOptions(
style: CodeEditor.ISelectionStyle
): CodeMirror.TextMarkerOptions {
let r = parseInt(style.color.slice(1, 3), 16);
let g = parseInt(style.color.slice(3, 5), 16);
let b = parseInt(style.color.slice(5, 7), 16);
let css = `background-color: rgba( ${r}, ${g}, ${b}, 0.15)`;
return {
className: style.className,
title: style.displayName,
css
};
}
/**
* Converts an editor selection to a code mirror selection.
*/
private _toCodeMirrorSelection(
selection: CodeEditor.IRange
): CodeMirror.Selection {
return {
anchor: this._toCodeMirrorPosition(selection.start),
head: this._toCodeMirrorPosition(selection.end)
};
}
/**
* Convert a code mirror position to an editor position.
*/
private _toPosition(position: CodeMirror.Position) {
return {
line: position.line,
column: position.ch
};
}
/**
* Convert an editor position to a code mirror position.
*/
private _toCodeMirrorPosition(position: CodeEditor.IPosition) {
return {
line: position.line,
ch: position.column
};
}
/**
* Handle model value changes.
*/
private _onValueChanged(
value: IObservableString,
args: IObservableString.IChangedArgs
): void {
if (this._changeGuard) {
return;
}
this._changeGuard = true;
let doc = this.doc;
switch (args.type) {
case 'insert':
let pos = doc.posFromIndex(args.start);
// Replace the range, including a '+input' orign,
// which indicates that CodeMirror may merge changes
// for undo/redo purposes.
doc.replaceRange(args.value, pos, pos, '+input');
break;
case 'remove':
let from = doc.posFromIndex(args.start);
let to = doc.posFromIndex(args.end);
// Replace the range, including a '+input' orign,
// which indicates that CodeMirror may merge changes
// for undo/redo purposes.
doc.replaceRange('', from, to, '+input');
break;
case 'set':
doc.setValue(args.value);
break;
default:
break;
}
this._changeGuard = false;
}
/**
* Handles document changes.
*/
private _beforeDocChanged(
doc: CodeMirror.Doc,
change: CodeMirror.EditorChange
) {
if (this._changeGuard) {
return;
}
this._changeGuard = true;
let value = this._model.value;
let start = doc.indexFromPos(change.from);
let end = doc.indexFromPos(change.to);
let inserted = change.text.join('\n');
if (end !== start) {
value.remove(start, end);
}
if (inserted) {
value.insert(start, inserted);
}
this._changeGuard = false;
}
/**
* Handle the DOM events for the editor.
*
* @param event - The DOM event sent to the editor.
*
* #### Notes
* This method implements the DOM `EventListener` interface and is
* called in response to events on the editor's DOM node. It should
* not be called directly by user code.
*/
handleEvent(event: Event): void {
switch (event.type) {
case 'focus':
this._evtFocus(event as FocusEvent);
break;
case 'blur':
this._evtBlur(event as FocusEvent);
break;
case 'scroll':
this._evtScroll();
break;
default:
break;
}
}
/**
* Handle `focus` events for the editor.
*/
private _evtFocus(event: FocusEvent): void {
if (this._needsRefresh) {
this.refresh();
}
this.host.classList.add('jp-mod-focused');
// Update the selections on editor gaining focus because
// the onCursorActivity function filters usual cursor events
// based on the editor's focus.
this._onCursorActivity();
}
/**
* Handle `blur` events for the editor.
*/
private _evtBlur(event: FocusEvent): void {
this.host.classList.remove('jp-mod-focused');
}
/**
* Handle `scroll` events for the editor.
*/
private _evtScroll(): void {
// Remove any active hover.
this._clearHover();
}
/**
* Clear the hover for a caret, due to things like
* scrolling, resizing, deactivation, etc, where
* the position is no longer valid.
*/
private _clearHover(): void {
if (this._caretHover) {
window.clearTimeout(this._hoverTimeout);
document.body.removeChild(this._caretHover);
this._caretHover = null;
}
}
/**
* Construct a caret element representing the position
* of a collaborator's cursor.
*/
private _getCaret(collaborator: ICollaborator): HTMLElement {
let name = collaborator ? collaborator.displayName : 'Anonymous';
let color = collaborator ? collaborator.color : this._selectionStyle.color;
let caret: HTMLElement = document.createElement('span');
caret.className = COLLABORATOR_CURSOR_CLASS;
caret.style.borderBottomColor = color;
caret.onmouseenter = () => {
this._clearHover();
this._hoverId = collaborator.sessionId;
let rect = caret.getBoundingClientRect();
// Construct and place the hover box.
let hover = document.createElement('div');
hover.className = COLLABORATOR_HOVER_CLASS;
hover.style.left = String(rect.left) + 'px';
hover.style.top = String(rect.bottom) + 'px';
hover.textContent = name;
hover.style.backgroundColor = color;
// If the user mouses over the hover, take over the timer.
hover.onmouseenter = () => {
window.clearTimeout(this._hoverTimeout);
};
hover.onmouseleave = () => {
this._hoverTimeout = window.setTimeout(() => {
this._clearHover();
}, HOVER_TIMEOUT);
};
this._caretHover = hover;
document.body.appendChild(hover);
};
caret.onmouseleave = () => {
this._hoverTimeout = window.setTimeout(() => {
this._clearHover();
}, HOVER_TIMEOUT);
};
return caret;
}
/**
* Check for an out of sync editor.
*/
private _checkSync(): void {
let change = this._lastChange;
if (!change) {
return;
}
this._lastChange = null;
let editor = this._editor;
let doc = editor.getDoc();
if (doc.getValue() === this._model.value.text) {
return;
}
void showDialog({
title: 'Code Editor out of Sync',
body:
'Please open your browser JavaScript console for bug report instructions'
});
console.log(
'Please paste the following to https://github.com/jupyterlab/jupyterlab/issues/2951'
);
console.log(
JSON.stringify({
model: this._model.value.text,
view: doc.getValue(),
selections: this.getSelections(),
cursor: this.getCursorPosition(),
lineSep: editor.getOption('lineSeparator'),
mode: editor.getOption('mode'),
change
})
);
}
private _model: CodeEditor.IModel;
private _editor: CodeMirror.Editor;
protected selectionMarkers: {
[key: string]: CodeMirror.TextMarker[] | undefined;
} = {};
private _caretHover: HTMLElement | null;
private readonly _config: CodeMirrorEditor.IConfig;
private _hoverTimeout: number;
private _hoverId: string;
private _keydownHandlers = new Array();
private _changeGuard = false;
private _selectionStyle: CodeEditor.ISelectionStyle;
private _uuid = '';
private _needsRefresh = false;
private _isDisposed = false;
private _lastChange: CodeMirror.EditorChange | null = null;
private _poll: Poll;
}
/**
* The namespace for `CodeMirrorEditor` statics.
*/
export namespace CodeMirrorEditor {
/**
* The options used to initialize a code mirror editor.
*/
export interface IOptions extends CodeEditor.IOptions {
/**
* The configuration options for the editor.
*/
config?: Partial;
}
/**
* The configuration options for a codemirror editor.
*/
export interface IConfig extends CodeEditor.IConfig {
/**
* The mode to use.
*/
mode?: string | Mode.IMode;
/**
* The theme to style the editor with.
* You must make sure the CSS file defining the corresponding
* .cm-s-[name] styles is loaded.
*/
theme?: string;
/**
* Whether to use the context-sensitive indentation that the mode provides
* (or just indent the same as the line before).
*/
smartIndent?: boolean;
/**
* Configures whether the editor should re-indent the current line when a
* character is typed that might change its proper indentation
* (only works if the mode supports indentation).
*/
electricChars?: boolean;
/**
* Configures the keymap to use. The default is "default", which is the
* only keymap defined in codemirror.js itself.
* Extra keymaps are found in the CodeMirror keymap directory.
*/
keyMap?: string;
/**
* Can be used to specify extra keybindings for the editor, alongside the
* ones defined by keyMap. Should be either null, or a valid keymap value.
*/
extraKeys?: any;
/**
* Can be used to add extra gutters (beyond or instead of the line number
* gutter).
* Should be an array of CSS class names, each of which defines a width
* (and optionally a background),
* and which will be used to draw the background of the gutters.
* May include the CodeMirror-linenumbers class, in order to explicitly
* set the position of the line number gutter
* (it will default to be to the right of all other gutters).
* These class names are the keys passed to setGutterMarker.
*/
gutters?: string[];
/**
* Determines whether the gutter scrolls along with the content
* horizontally (false)
* or whether it stays fixed during horizontal scrolling (true,
* the default).
*/
fixedGutter?: boolean;
/**
* Whether the folding gutter should be drawn
*/
foldGutter?: boolean;
/**
* Whether the cursor should be drawn when a selection is active.
*/
showCursorWhenSelecting?: boolean;
/**
* When fixedGutter is on, and there is a horizontal scrollbar, by default
* the gutter will be visible to the left of this scrollbar. If this
* option is set to true, it will be covered by an element with class
* CodeMirror-gutter-filler.
*/
coverGutterNextToScrollbar?: boolean;
/**
* Controls whether drag-and-drop is enabled.
*/
dragDrop?: boolean;
/**
* Explicitly set the line separator for the editor.
* By default (value null), the document will be split on CRLFs as well as
* lone CRs and LFs, and a single LF will be used as line separator in all
* output (such as getValue). When a specific string is given, lines will
* only be split on that string, and output will, by default, use that
* same separator.
*/
lineSeparator?: string | null;
/**
* Chooses a scrollbar implementation. The default is "native", showing
* native scrollbars. The core library also provides the "null" style,
* which completely hides the scrollbars. Addons can implement additional
* scrollbar models.
*/
scrollbarStyle?: string;
/**
* When enabled, which is the default, doing copy or cut when there is no
* selection will copy or cut the whole lines that have cursors on them.
*/
lineWiseCopyCut?: boolean;
/**
* Whether to scroll past the end of the buffer.
*/
scrollPastEnd?: boolean;
/**
* Whether to give the wrapper of the line that contains the cursor the class
* CodeMirror-activeline, adds a background with the class
* CodeMirror-activeline-background, and adds the class
* CodeMirror-activeline-gutter to the line's gutter space is enabled.
*/
styleActiveLine: boolean | CodeMirror.StyleActiveLine;
/**
* Whether to causes the selected text to be marked with the CSS class
* CodeMirror-selectedtext. Useful to change the colour of the selection
* (in addition to the background).
*/
styleSelectedText: boolean;
/**
* Defines the mouse cursor appearance when hovering over the selection.
* It can be set to a string, like "pointer", or to true,
* in which case the "default" (arrow) cursor will be used.
*/
selectionPointer: boolean | string;
}
/**
* The default configuration options for an editor.
*/
export let defaultConfig: IConfig = {
...CodeEditor.defaultConfig,
mode: 'null',
theme: 'jupyter',
smartIndent: true,
electricChars: true,
keyMap: 'default',
extraKeys: null,
gutters: [],
fixedGutter: true,
showCursorWhenSelecting: false,
coverGutterNextToScrollbar: false,
dragDrop: true,
lineSeparator: null,
scrollbarStyle: 'native',
lineWiseCopyCut: true,
scrollPastEnd: false,
styleActiveLine: false,
styleSelectedText: true,
selectionPointer: false,
rulers: [],
foldGutter: false
};
/**
* Add a command to CodeMirror.
*
* @param name - The name of the command to add.
*
* @param command - The command function.
*/
export function addCommand(
name: string,
command: (cm: CodeMirror.Editor) => void
) {
(CodeMirror.commands as any)[name] = command;
}
}
/**
* The namespace for module private data.
*/
namespace Private {
export function createEditor(
host: HTMLElement,
config: CodeMirrorEditor.IConfig
): CodeMirror.Editor {
let {
autoClosingBrackets,
fontFamily,
fontSize,
insertSpaces,
lineHeight,
lineWrap,
wordWrapColumn,
tabSize,
readOnly,
...otherOptions
} = config;
let bareConfig = {
autoCloseBrackets: autoClosingBrackets ? {} : false,
indentUnit: tabSize,
indentWithTabs: !insertSpaces,
lineWrapping: lineWrap === 'off' ? false : true,
readOnly,
...otherOptions
};
return CodeMirror(el => {
if (fontFamily) {
el.style.fontFamily = fontFamily;
}
if (fontSize) {
el.style.fontSize = fontSize + 'px';
}
if (lineHeight) {
el.style.lineHeight = lineHeight.toString();
}
if (readOnly) {
el.classList.add(READ_ONLY_CLASS);
}
if (lineWrap === 'wordWrapColumn') {
const lines = el.querySelector('.CodeMirror-lines') as HTMLDivElement;
lines.style.width = `${wordWrapColumn}ch`;
}
if (lineWrap === 'bounded') {
const lines = el.querySelector('.CodeMirror-lines') as HTMLDivElement;
lines.style.maxWidth = `${wordWrapColumn}ch`;
}
host.appendChild(el);
}, bareConfig);
}
/**
* Indent or insert a tab as appropriate.
*/
export function indentMoreOrinsertTab(cm: CodeMirror.Editor): void {
let doc = cm.getDoc();
let from = doc.getCursor('from');
let to = doc.getCursor('to');
let sel = !posEq(from, to);
if (sel) {
CodeMirror.commands['indentMore'](cm);
return;
}
// Check for start of line.
let line = doc.getLine(from.line);
let before = line.slice(0, from.ch);
if (/^\s*$/.test(before)) {
CodeMirror.commands['indentMore'](cm);
} else {
if (cm.getOption('indentWithTabs')) {
CodeMirror.commands['insertTab'](cm);
} else {
CodeMirror.commands['insertSoftTab'](cm);
}
}
}
/**
* Delete spaces to the previous tab stob in a codemirror editor.
*/
export function delSpaceToPrevTabStop(cm: CodeMirror.Editor): void {
let doc = cm.getDoc();
let from = doc.getCursor('from');
let to = doc.getCursor('to');
let sel = !posEq(from, to);
if (sel) {
let ranges = doc.listSelections();
for (let i = ranges.length - 1; i >= 0; i--) {
let head = ranges[i].head;
let anchor = ranges[i].anchor;
doc.replaceRange(
'',
CodeMirror.Pos(head.line, head.ch),
CodeMirror.Pos(anchor.line, anchor.ch)
);
}
return;
}
let cur = doc.getCursor();
let tabsize = cm.getOption('indentUnit');
let chToPrevTabStop = cur.ch - (Math.ceil(cur.ch / tabsize) - 1) * tabsize;
from = { ch: cur.ch - chToPrevTabStop, line: cur.line };
let select = doc.getRange(from, cur);
if (select.match(/^\ +$/) !== null) {
doc.replaceRange('', from, cur);
} else {
CodeMirror.commands['delCharBefore'](cm);
}
}
/**
* Test whether two CodeMirror positions are equal.
*/
export function posEq(
a: CodeMirror.Position,
b: CodeMirror.Position
): boolean {
return a.line === b.line && a.ch === b.ch;
}
/**
* Get the list of active gutters
*
* @param config Editor configuration
*/
function getActiveGutters(config: CodeMirrorEditor.IConfig): string[] {
// The order of the classes will be the gutters order
let classToSwitch: { [val: string]: keyof CodeMirrorEditor.IConfig } = {
'CodeMirror-linenumbers': 'lineNumbers',
'CodeMirror-foldgutter': 'codeFolding'
};
return Object.keys(classToSwitch).filter(
gutter => config[classToSwitch[gutter]]
);
}
/**
* Set a config option for the editor.
*/
export function setOption(
editor: CodeMirror.Editor,
option: K,
value: CodeMirrorEditor.IConfig[K],
config: CodeMirrorEditor.IConfig
): void {
let el = editor.getWrapperElement();
switch (option) {
case 'lineWrap':
const lineWrapping = value === 'off' ? false : true;
const lines = el.querySelector('.CodeMirror-lines') as HTMLDivElement;
const maxWidth =
value === 'bounded' ? `${config.wordWrapColumn}ch` : null;
const width =
value === 'wordWrapColumn' ? `${config.wordWrapColumn}ch` : null;
lines.style.maxWidth = maxWidth;
lines.style.width = width;
editor.setOption('lineWrapping', lineWrapping);
break;
case 'wordWrapColumn':
const { lineWrap } = config;
if (lineWrap === 'wordWrapColumn' || lineWrap === 'bounded') {
const lines = el.querySelector('.CodeMirror-lines') as HTMLDivElement;
const prop = lineWrap === 'wordWrapColumn' ? 'width' : 'maxWidth';
lines.style[prop] = `${value}ch`;
}
break;
case 'tabSize':
editor.setOption('indentUnit', value);
break;
case 'insertSpaces':
editor.setOption('indentWithTabs', !value);
break;
case 'autoClosingBrackets':
editor.setOption('autoCloseBrackets', value);
break;
case 'rulers':
let rulers = value as Array;
editor.setOption(
'rulers',
rulers.map(column => {
return {
column,
className: 'jp-CodeMirror-ruler'
};
})
);
break;
case 'readOnly':
el.classList.toggle(READ_ONLY_CLASS, value);
editor.setOption(option, value);
break;
case 'fontFamily':
el.style.fontFamily = value;
break;
case 'fontSize':
el.style.fontSize = value ? value + 'px' : null;
break;
case 'lineHeight':
el.style.lineHeight = value ? value.toString() : null;
break;
case 'gutters':
editor.setOption(option, getActiveGutters(config));
break;
case 'lineNumbers':
editor.setOption(option, value);
editor.setOption('gutters', getActiveGutters(config));
break;
case 'codeFolding':
editor.setOption('foldGutter', value);
editor.setOption('gutters', getActiveGutters(config));
break;
default:
editor.setOption(option, value);
break;
}
}
}
/**
* Add a CodeMirror command to delete until previous non blanking space
* character or first multiple of tabsize tabstop.
*/
CodeMirrorEditor.addCommand(
'delSpaceToPrevTabStop',
Private.delSpaceToPrevTabStop
);
/**
* Add a CodeMirror command to indent or insert a tab as appropriate.
*/
CodeMirrorEditor.addCommand(
'indentMoreOrinsertTab',
Private.indentMoreOrinsertTab
);