UNPKG

react-simple-code-editor

Version:

Simple no-frills code editor with syntax highlighting

510 lines (437 loc) 13.7 kB
/* @flow */ /* global global */ /* eslint-disable react/no-danger */ import * as React from 'react'; type Props = { // Props for the component value: string, onValueChange: (value: string) => mixed, highlight: (value: string) => string, tabSize: number, insertSpaces: boolean, ignoreTabKey: boolean, padding: number | string, style?: {}, // Props for the textarea autoFocus?: boolean, disabled?: boolean, form?: string, maxLength?: number, minLength?: number, name?: string, readOnly?: boolean, required?: boolean, onFocus?: (e: FocusEvent) => mixed, onBlur?: (e: FocusEvent) => mixed, }; type State = { capture: boolean, }; type Record = { value: string, selectionStart: number, selectionEnd: number, }; type History = { stack: Array<Record & { timestamp: number }>, offset: number, }; const KEYCODE_ENTER = 13; const KEYCODE_TAB = 9; const KEYCODE_BACKSPACE = 8; const KEYCODE_Y = 89; const KEYCODE_Z = 90; const KEYCODE_M = 77; const HISTORY_LIMIT = 100; const HISTORY_TIME_GAP = 3000; const isWindows = 'navigator' in global && /Win/i.test(navigator.platform); const isMacLike = 'navigator' in global && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); export default class Editor extends React.Component<Props, State> { static defaultProps = { tabSize: 2, insertSpaces: true, ignoreTabKey: false, padding: 0, }; state = { capture: true, }; componentDidMount() { this._recordCurrentState(); } _recordCurrentState = () => { const input = this._input; if (!input) return; // Save current state of the input const { value, selectionStart, selectionEnd } = input; this._recordChange({ value, selectionStart, selectionEnd, }); }; _getLines = (text: string, position: number) => text.substring(0, position).split('\n'); _recordChange = (record: Record, overwrite?: boolean = false) => { const { stack, offset } = this._history; if (stack.length && offset > -1) { // When something updates, drop the redo operations this._history.stack = stack.slice(0, offset + 1); // Limit the number of operations to 100 const count = this._history.stack.length; if (count > HISTORY_LIMIT) { const extras = count - HISTORY_LIMIT; this._history.stack = stack.slice(extras, count); this._history.offset = Math.max(this._history.offset - extras, 0); } } const timestamp = Date.now(); if (overwrite) { const last = this._history.stack[this._history.offset]; if (last && timestamp - last.timestamp < HISTORY_TIME_GAP) { // A previous entry exists and was in short interval // Match the last word in the line const re = /[^a-z0-9]([a-z0-9]+)$/i; // Get the previous line const previous = this._getLines(last.value, last.selectionStart) .pop() .match(re); // Get the current line const current = this._getLines(record.value, record.selectionStart) .pop() .match(re); if (previous && current && current[1].startsWith(previous[1])) { // The last word of the previous line and current line match // Overwrite previous entry so that undo will remove whole word this._history.stack[this._history.offset] = { ...record, timestamp }; return; } } } // Add the new operation to the stack this._history.stack.push({ ...record, timestamp }); this._history.offset++; }; _updateInput = (record: Record) => { const input = this._input; if (!input) return; // Update values and selection state input.value = record.value; input.selectionStart = record.selectionStart; input.selectionEnd = record.selectionEnd; this.props.onValueChange(record.value); }; _applyEdits = (record: Record) => { // Save last selection state const input = this._input; const last = this._history.stack[this._history.offset]; if (last && input) { this._history.stack[this._history.offset] = { ...last, selectionStart: input.selectionStart, selectionEnd: input.selectionEnd, }; } // Save the changes this._recordChange(record); this._updateInput(record); }; _undoEdit = () => { const { stack, offset } = this._history; // Get the previous edit const record = stack[offset - 1]; if (record) { // Apply the changes and update the offset this._updateInput(record); this._history.offset = Math.max(offset - 1, 0); } }; _redoEdit = () => { const { stack, offset } = this._history; // Get the next edit const record = stack[offset + 1]; if (record) { // Apply the changes and update the offset this._updateInput(record); this._history.offset = Math.min(offset + 1, stack.length - 1); } }; _handleKeyDown = (e: *) => { const { tabSize, insertSpaces, ignoreTabKey } = this.props; const { value, selectionStart, selectionEnd } = e.target; const tabCharacter = (insertSpaces ? ' ' : ' ').repeat(tabSize); if (e.keyCode === KEYCODE_TAB && !ignoreTabKey && this.state.capture) { // Prevent focus change e.preventDefault(); if (e.shiftKey) { // Unindent selected lines const linesBeforeCaret = this._getLines(value, selectionStart); const startLine = linesBeforeCaret.length - 1; const endLine = this._getLines(value, selectionEnd).length - 1; const nextValue = value .split('\n') .map((line, i) => { if ( i >= startLine && i <= endLine && line.startsWith(tabCharacter) ) { return line.substring(tabCharacter.length); } return line; }) .join('\n'); if (value !== nextValue) { const startLineText = linesBeforeCaret[startLine]; this._applyEdits({ value: nextValue, // Move the start cursor if first line in selection was modified // It was modified only if it started with a tab selectionStart: startLineText.startsWith(tabCharacter) ? selectionStart - tabCharacter.length : selectionStart, // Move the end cursor by total number of characters removed selectionEnd: selectionEnd - (value.length - nextValue.length), }); } } else if (selectionStart !== selectionEnd) { // Indent selected lines const linesBeforeCaret = this._getLines(value, selectionStart); const startLine = linesBeforeCaret.length - 1; const endLine = this._getLines(value, selectionEnd).length - 1; const startLineText = linesBeforeCaret[startLine]; this._applyEdits({ value: value .split('\n') .map((line, i) => { if (i >= startLine && i <= endLine) { return tabCharacter + line; } return line; }) .join('\n'), // Move the start cursor by number of characters added in first line of selection // Don't move it if it there was no text before cursor selectionStart: /\S/.test(startLineText) ? selectionStart + tabCharacter.length : selectionStart, // Move the end cursor by total number of characters added selectionEnd: selectionEnd + tabCharacter.length * (endLine - startLine + 1), }); } else { const updatedSelection = selectionStart + tabCharacter.length; this._applyEdits({ // Insert tab character at caret value: value.substring(0, selectionStart) + tabCharacter + value.substring(selectionEnd), // Update caret position selectionStart: updatedSelection, selectionEnd: updatedSelection, }); } } else if (e.keyCode === KEYCODE_BACKSPACE) { const hasSelection = selectionStart !== selectionEnd; const textBeforeCaret = value.substring(0, selectionStart); if (textBeforeCaret.endsWith(tabCharacter) && !hasSelection) { // Prevent default delete behaviour e.preventDefault(); const updatedSelection = selectionStart - tabCharacter.length; this._applyEdits({ // Remove tab character at caret value: value.substring(0, selectionStart - tabCharacter.length) + value.substring(selectionEnd), // Update caret position selectionStart: updatedSelection, selectionEnd: updatedSelection, }); } } else if (e.keyCode === KEYCODE_ENTER) { // Ignore selections if (selectionStart === selectionEnd) { // Get the current line const line = this._getLines(value, selectionStart).pop(); const matches = line.match(/^\s+/); if (matches && matches[0]) { e.preventDefault(); // Preserve indentation on inserting a new line const indent = '\n' + matches[0]; const updatedSelection = selectionStart + indent.length; this._applyEdits({ // Insert indentation character at caret value: value.substring(0, selectionStart) + indent + value.substring(selectionEnd), // Update caret position selectionStart: updatedSelection, selectionEnd: updatedSelection, }); } } } else if ( (isMacLike ? // Trigger undo with ⌘+Z on Mac e.metaKey && e.keyCode === KEYCODE_Z : // Trigger undo with Ctrl+Z on other platforms e.ctrlKey && e.keyCode === KEYCODE_Z) && !e.shiftKey && !e.altKey ) { e.preventDefault(); this._undoEdit(); } else if ( (isMacLike ? // Trigger redo with ⌘+Shift+Z on Mac e.metaKey && e.keyCode === KEYCODE_Z && e.shiftKey : isWindows ? // Trigger redo with Ctrl+Y on Windows e.ctrlKey && e.keyCode === KEYCODE_Y : // Trigger redo with Ctrl+Shift+Z on other platforms e.ctrlKey && e.keyCode === KEYCODE_Z && e.shiftKey) && !e.altKey ) { e.preventDefault(); this._redoEdit(); } else if ( e.keyCode === KEYCODE_M && e.ctrlKey && (isMacLike ? e.shiftKey : true) ) { e.preventDefault(); // Toggle capturing tab key so users can focus away this.setState(state => ({ capture: !state.capture, })); } }; _handleChange = (e: *) => { const { value, selectionStart, selectionEnd } = e.target; this._recordChange( { value, selectionStart, selectionEnd, }, true ); this.props.onValueChange(value); }; _history: History = { stack: [], offset: -1, }; _input: ?HTMLTextAreaElement; render() { const { value, style, padding, highlight, autoFocus, disabled, form, maxLength, minLength, name, readOnly, required, onFocus, onBlur, /* eslint-disable no-unused-vars */ onValueChange, tabSize, insertSpaces, ignoreTabKey, /* eslint-enable no-unused-vars */ ...rest } = this.props; const contentStyle = { paddingTop: padding, paddingRight: padding, paddingBottom: padding, paddingLeft: padding, }; return ( <div {...rest} style={{ ...styles.container, ...style }}> <textarea ref={c => (this._input = c)} style={{ ...styles.editor, ...styles.textarea, ...contentStyle }} value={value} onChange={this._handleChange} onKeyDown={this._handleKeyDown} onFocus={onFocus} onBlur={onBlur} disabled={disabled} form={form} maxLength={maxLength} minLength={minLength} name={name} readOnly={readOnly} required={required} autoFocus={autoFocus} autoCapitalize="off" autoComplete="off" autoCorrect="off" spellCheck={false} data-gramm={false} /> <pre aria-hidden="true" style={{ ...styles.editor, ...styles.highlight, ...contentStyle }} dangerouslySetInnerHTML={{ __html: highlight(value) + '<br />' }} /> </div> ); } } const styles = { container: { position: 'relative', textAlign: 'left', whiteSpace: 'pre-wrap', padding: 0, }, textarea: { position: 'absolute', top: 0, left: 0, height: '100%', width: '100%', margin: 0, border: 0, resize: 'none', background: 'none', overflow: 'hidden', color: 'inherit', MozOsxFontSmoothing: 'grayscale', WebkitFontSmoothing: 'antialiased', WebkitTextFillColor: 'transparent', }, highlight: { position: 'relative', margin: 0, border: 0, pointerEvents: 'none', }, editor: { boxSizing: 'inherit', display: 'inherit', fontFamily: 'inherit', fontSize: 'inherit', fontStyle: 'inherit', fontVariantLigatures: 'inherit', fontWeight: 'inherit', letterSpacing: 'inherit', lineHeight: 'inherit', tabSize: 'inherit', textIndent: 'inherit', textRendering: 'inherit', textTransform: 'inherit', whiteSpace: 'inherit', }, };