react-simple-code-editor
Version:
Simple no-frills code editor with syntax highlighting
510 lines (437 loc) • 13.7 kB
Flow
/* @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',
},
};