/* * The MIT License (MIT) * * Copyright (c) 2015 - present Instructure, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { Component } from 'react' import codemirror from 'codemirror' // Language modes import 'codemirror/mode/jsx/jsx' import 'codemirror/mode/shell/shell' import 'codemirror/mode/css/css' import 'codemirror/mode/htmlmixed/htmlmixed' import 'codemirror/mode/markdown/markdown' import 'codemirror/mode/yaml/yaml' class Controlled extends Component { /** @internal */ private appliedNext: boolean /** @internal */ private deferred: any /** @internal */ private editor!: codemirror.Editor /** @internal */ private emulating: boolean /** @internal */ private hydrated: boolean /** @internal */ private initCb: () => void /** @internal */ private mirror: any /** @internal */ private mounted: boolean /** @internal */ private ref!: HTMLElement /** @internal */ private shared!: Shared /** @internal */ constructor(props: IControlledCodeMirror) { super(props) this.appliedNext = false this.deferred = null this.emulating = false this.hydrated = false this.initCb = () => { if (this.props.editorDidConfigure) { this.props.editorDidConfigure(this.editor) } } this.mounted = false } /** @internal */ private hydrate(props: IControlledCodeMirror) { const _options = props && props.options ? props.options : {} const userDefinedOptions = Object.assign( {}, codemirror.defaults, (this.editor as any).options, _options ) const optionDelta = Object.keys(userDefinedOptions).some( (key) => this.editor.getOption(key as any) !== userDefinedOptions[key] ) if (optionDelta) { Object.keys(userDefinedOptions).forEach((key) => { // eslint-disable-next-line no-prototype-builtins if (_options.hasOwnProperty(key)) { if (this.editor.getOption(key as any) !== userDefinedOptions[key]) { this.editor.setOption(key as any, userDefinedOptions[key]) this.mirror.setOption(key as any, userDefinedOptions[key]) } } }) } if (!this.hydrated) { this.deferred ? this.resolveChange(props.value) : this.initChange(props.value || '') } this.hydrated = true } /** @internal */ private initChange(value: string) { this.emulating = true const doc = this.editor.getDoc() const lastLine = doc.lastLine() const lastChar = doc.getLine(doc.lastLine()).length doc.replaceRange( value || '', { line: 0, ch: 0 }, { line: lastLine, ch: lastChar } ) this.mirror.setValue(value) doc.clearHistory() this.mirror.clearHistory() this.emulating = false } /** @internal */ private resolveChange(value: string) { this.emulating = true const doc = this.editor.getDoc() if (this.deferred.origin === 'undo') { doc.undo() } else if (this.deferred.origin === 'redo') { doc.redo() } else { doc.replaceRange( this.deferred.text, this.deferred.from, this.deferred.to, this.deferred.origin ) } if (value && value !== doc.getValue()) { const cursor = doc.getCursor() doc.setValue(value) doc.setCursor(cursor) } this.emulating = false this.deferred = null } /** @internal */ private mirrorChange(deferred: Record) { const doc = this.editor.getDoc() if (deferred.origin === 'undo') { doc.setHistory(this.mirror.getHistory()) this.mirror.undo() } else if (deferred.origin === 'redo') { doc.setHistory(this.mirror.getHistory()) this.mirror.redo() } else { this.mirror.replaceRange( deferred.text, deferred.from, deferred.to, deferred.origin ) } return this.mirror.getValue() } /** @internal */ public componentDidMount() { if (this.props.defineMode) { if (this.props.defineMode.name && this.props.defineMode.fn) { codemirror.defineMode( this.props.defineMode.name, this.props.defineMode.fn ) } } this.editor = codemirror(this.ref, this.props.options) as codemirror.Editor this.shared = new Shared(this.editor, this.props) this.mirror = (codemirror as any)(() => {}, this.props.options) this.editor.on('electricInput', () => { this.mirror.setHistory(this.editor.getDoc().getHistory()) }) this.editor.on('cursorActivity', () => { this.mirror.setCursor(this.editor.getDoc().getCursor()) }) this.editor.on('beforeChange', (_cm, data) => { if (this.emulating) { return } data.cancel() this.deferred = data const phantomChange = this.mirrorChange(this.deferred) if (this.props.onBeforeChange) this.props.onBeforeChange(this.editor, this.deferred, phantomChange) }) this.editor.on('change', (_cm, data) => { if (!this.mounted) { return } if (this.props.onChange) { this.props.onChange(this.editor, data, this.editor.getValue()) } }) this.hydrate(this.props) this.shared.apply(this.props) this.mounted = true this.shared.wire(this.props) if (this.editor.getOption('autofocus')) { this.editor.focus() } if (this.props.editorDidMount) { this.props.editorDidMount( this.editor, this.editor.getValue(), this.initCb ) } } /** @internal */ public componentDidUpdate(prevProps: IControlledCodeMirror) { const preserved: IPreservedOptions = { cursor: undefined } if (this.props.value !== prevProps.value) { this.hydrated = false } if (!this.props.autoCursor && this.props.autoCursor !== undefined) { preserved.cursor = this.editor.getDoc().getCursor() } this.hydrate(this.props) if (!this.appliedNext) { this.shared.applyNext(prevProps, this.props, preserved) this.appliedNext = true } this.shared.applyUserDefined(prevProps, preserved) } /** @internal */ public componentWillUnmount() { if (this.props.editorWillUnmount) { this.props.editorWillUnmount(codemirror) } } /** @internal */ public shouldComponentUpdate() { return true } /** @internal */ public render() { const className = this.props.className ? `react-codemirror2 ${this.props.className}` : 'react-codemirror2' return
(this.ref = self!)} /> } } export interface IDefineModeOptions { fn: () => codemirror.Mode name: string } export interface ISetScrollOptions { x?: number | null y?: number | null } export interface ISetSelectionOptions { anchor: codemirror.Position head: codemirror.Position } export interface DomEvent { (editor: codemirror.Editor, event?: any): void } export interface KeyHandledEvent { (editor: codemirror.Editor, name: string, event: any): void } export interface EditorChangeEvent { (editor: codemirror.Editor, changeObj: codemirror.EditorChange): void } export interface ICodeMirror { autoCursor?: boolean // default: true autoScroll?: boolean // default: false className?: string cursor?: codemirror.Position defineMode?: IDefineModeOptions editorDidConfigure?: (editor: codemirror.Editor) => void editorDidMount?: ( editor: codemirror.Editor, value: string, cb: () => void ) => void editorWillUnmount?: (lib: any) => void onBlur?: DomEvent onChange?: ( editor: codemirror.Editor, data: codemirror.EditorChange, value: string ) => void onContextMenu?: DomEvent onCopy?: DomEvent onCursor?: (editor: codemirror.Editor, data: codemirror.Position) => void onCut?: DomEvent onCursorActivity?: (editor: codemirror.Editor) => void onDblClick?: DomEvent onDragEnter?: DomEvent onDragLeave?: DomEvent onDragOver?: DomEvent onDragStart?: DomEvent onDrop?: DomEvent onFocus?: DomEvent onGutterClick?: ( editor: codemirror.Editor, lineNumber: number, gutter: string, event: Event ) => void onInputRead?: EditorChangeEvent onKeyDown?: DomEvent onKeyHandled?: KeyHandledEvent onKeyPress?: DomEvent onKeyUp?: DomEvent onMouseDown?: DomEvent onPaste?: DomEvent onRenderLine?: ( editor: codemirror.Editor, line: codemirror.LineHandle, element: HTMLElement ) => void onScroll?: (editor: codemirror.Editor, data: codemirror.ScrollInfo) => void onSelection?: (editor: codemirror.Editor, data: any) => void onTouchStart?: DomEvent onUpdate?: (editor: codemirror.Editor) => void onViewportChange?: ( editor: codemirror.Editor, start: number, end: number ) => void options?: codemirror.EditorConfiguration selection?: { ranges: Array; focus?: boolean } scroll?: ISetScrollOptions } export interface IControlledCodeMirror extends ICodeMirror { onBeforeChange: ( editor: codemirror.Editor, data: codemirror.EditorChange, value: string ) => void value: string } export interface IUnControlledCodeMirror extends ICodeMirror { detach?: boolean editorDidAttach?: (editor: codemirror.Editor) => void editorDidDetach?: (editor: codemirror.Editor) => void onBeforeChange?: ( editor: codemirror.Editor, data: codemirror.EditorChange, value: string, next: () => void ) => void value?: string } declare interface ICommon { wire: (props: IControlledCodeMirror | IUnControlledCodeMirror) => void apply: (props: IControlledCodeMirror | IUnControlledCodeMirror) => void applyNext: ( props: IControlledCodeMirror | IUnControlledCodeMirror, next?: IControlledCodeMirror | IUnControlledCodeMirror, preserved?: IPreservedOptions ) => void applyUserDefined: ( props: IControlledCodeMirror | IUnControlledCodeMirror, preserved?: IPreservedOptions ) => void } declare interface IPreservedOptions { cursor?: codemirror.Position } abstract class Helper { public static equals( x: Record, y: Record ): boolean { const ok = Object.keys, tx = typeof x, ty = typeof y return x && y && tx === 'object' && tx === ty ? ok(x).length === ok(y).length && ok(x).every((key) => this.equals(x[key], y[key])) : x === y } } export class Shared implements ICommon { private readonly editor: codemirror.Editor private props: ICodeMirror constructor(editor: codemirror.Editor, props: IControlledCodeMirror) { this.editor = editor this.props = props } delegateCursor( position: codemirror.Position, scroll?: boolean, focus?: boolean ) { const doc = this.editor.getDoc() as codemirror.Doc if (focus) { this.editor.focus() } scroll ? doc.setCursor(position) : doc.setCursor(position, undefined, { scroll: false }) } delegateScroll(coordinates: ISetScrollOptions) { this.editor.scrollTo(coordinates.x, coordinates.y) } delegateSelection(ranges: Array, focus?: boolean) { const doc = this.editor.getDoc() as codemirror.Doc doc.setSelections(ranges) if (focus) { this.editor.focus() } } public apply(props: IControlledCodeMirror | IUnControlledCodeMirror) { // init ranges if (props && props.selection && props.selection.ranges) { this.delegateSelection( props.selection.ranges, props.selection.focus || false ) } // init cursor if (props && props.cursor) { this.delegateCursor( props.cursor, props.autoScroll || false, this.editor.getOption('autofocus') || false ) } // init scroll if (props && props.scroll) { this.delegateScroll(props.scroll) } } public applyNext( props: IControlledCodeMirror | IUnControlledCodeMirror, next?: IControlledCodeMirror | IUnControlledCodeMirror, preserved?: any ) { // handle new ranges if (props && props.selection && props.selection.ranges) { if ( next && next.selection && next.selection.ranges && !Helper.equals(props.selection.ranges, next.selection.ranges) ) { this.delegateSelection( next.selection.ranges, next.selection.focus || false ) } } // handle new cursor if (props && props.cursor) { if (next && next.cursor && !Helper.equals(props.cursor, next.cursor)) { this.delegateCursor( preserved.cursor || next.cursor, next.autoScroll || false, next.autoCursor || false ) } } // handle new scroll if (props && props.scroll) { if (next && next.scroll && !Helper.equals(props.scroll, next.scroll)) { this.delegateScroll(next.scroll) } } } public applyUserDefined( props: IControlledCodeMirror | IUnControlledCodeMirror, preserved?: any ) { if (preserved && preserved.cursor) { this.delegateCursor( preserved.cursor, props.autoScroll || false, this.editor.getOption('autofocus') || false ) } } public wire(props: IControlledCodeMirror | IUnControlledCodeMirror) { Object.keys(props || {}) .filter((p) => /^on/.test(p)) .forEach((prop) => { switch (prop) { case 'onBlur': { ;(this.editor as any).on('blur', (_cm: any, event: any) => { this.props.onBlur?.(this.editor, event) }) } break case 'onContextMenu': { this.editor.on('contextmenu', (_cm, event) => { this.props.onContextMenu?.(this.editor, event) }) break } case 'onCopy': { this.editor.on('copy', (_cm, event?) => { this.props.onCopy?.(this.editor, event) }) break } case 'onCursor': { this.editor.on('cursorActivity', () => { this.props.onCursor?.( this.editor, this.editor.getDoc().getCursor() ) }) } break case 'onCursorActivity': { this.editor.on('cursorActivity', () => { this.props.onCursorActivity?.(this.editor) }) } break case 'onCut': { this.editor.on('cut', (_cm, event?) => { this.props.onCut?.(this.editor, event) }) break } case 'onDblClick': { this.editor.on('dblclick', (_cm, event) => { this.props.onDblClick?.(this.editor, event) }) break } case 'onDragEnter': { this.editor.on('dragenter', (_cm, event) => { this.props.onDragEnter?.(this.editor, event) }) } break case 'onDragLeave': { this.editor.on('dragleave', (_cm, event) => { this.props.onDragLeave?.(this.editor, event) }) break } case 'onDragOver': { this.editor.on('dragover', (_cm, event) => { this.props.onDragOver?.(this.editor, event) }) } break case 'onDragStart': { this.editor.on('dragstart', (_cm, event) => { this.props.onDragStart?.(this.editor, event) }) break } case 'onDrop': { this.editor.on('drop', (_cm, event) => { this.props.onDrop?.(this.editor, event) }) } break case 'onFocus': { ;(this.editor as any).on('focus', (_cm: any, event: Event) => { this.props.onFocus?.(this.editor, event) }) } break case 'onGutterClick': { this.editor.on( 'gutterClick', (_cm, lineNumber, gutter, event) => { this.props.onGutterClick?.( this.editor, lineNumber, gutter, event ) } ) } break case 'onInputRead': { this.editor.on('inputRead', (_cm, EditorChangeEvent) => { this.props.onInputRead?.(this.editor, EditorChangeEvent) }) } break case 'onKeyDown': { this.editor.on('keydown', (_cm, event) => { this.props.onKeyDown?.(this.editor, event) }) } break case 'onKeyHandled': { this.editor.on('keyHandled', (_cm, key, event) => { this.props.onKeyHandled?.(this.editor, key, event) }) } break case 'onKeyPress': { this.editor.on('keypress', (_cm, event) => { this.props.onKeyPress?.(this.editor, event) }) } break case 'onKeyUp': { this.editor.on('keyup', (_cm, event) => { this.props.onKeyUp?.(this.editor, event) }) } break case 'onMouseDown': { this.editor.on('mousedown', (_cm, event) => { this.props.onMouseDown?.(this.editor, event) }) break } case 'onPaste': { this.editor.on('paste', (_cm, event?) => { this.props.onPaste?.(this.editor, event) }) break } case 'onRenderLine': { this.editor.on('renderLine', (_cm, line, element) => { this.props.onRenderLine?.(this.editor, line, element) }) break } case 'onScroll': { this.editor.on('scroll', (_cm) => { this.props.onScroll?.(this.editor, this.editor.getScrollInfo()) }) } break case 'onSelection': { this.editor.on('beforeSelectionChange', (_cm, data) => { this.props.onSelection?.(this.editor, data) }) } break case 'onTouchStart': { this.editor.on('touchstart', (_cm, event) => { this.props.onTouchStart?.(this.editor, event) }) break } case 'onUpdate': { this.editor.on('update', (_cm) => { this.props.onUpdate?.(this.editor) }) } break case 'onViewportChange': { this.editor.on('viewportChange', (_cm, from, to) => { this.props.onViewportChange?.(this.editor, from, to) }) } break } }) } } export default Controlled