Skip to content

Commit

Permalink
Add source editor component (Graylog2#4419)
Browse files Browse the repository at this point in the history
* Add react-ace dependency

* Add first version of SourceCodeEditor

* Add read-only flag to SourceCodeEditor

* Make SourceCodeEditor resizable

* Do not require onChange prop

* Allow to resize editor in all axes

This is unfortunately not possible when setting `width` to `Infinity`,
but at least can be used when it has other values.

* Do not clear editor textarea on undo

Reset undo history when the editor is fully loaded to avoid that users
clear textare performing an undo command.

The problem behind this is that the editor is created empty and ReactAce
does a `setValue` to set the given value for the editor. This is
registered as a change by ace, so when pressing undo in an apparently
not-changed input, the input is reset.

Fixes Graylog2/graylog-plugin-pipeline-processor#224

* Add property to disable ClipboardButton

* Add toolbar with undo/redo controls

* Enable/disable undo and redo buttons

* Add copy/paste buttons

Copying uses our ClipboardButton component that works without external
dependencies.
Pasting is currently not possible without the user triggering a system
event for it, so we just show a small tooltip.

* Add support for annotations

* Add button titles to toolbar actions

Hovering over the buttons should display a tooltip with the action they
perform.

* Adapt copy message to macOS too

* Improve message when clicking on paste button

* Make source editor non-resizable

* Add support for multiple modes

We only add support for languages that make more sense to write from the
web interface.

* Disable copy and paste buttons for read-only editors

* Avoid some computations when disabled

- Do not resize editor when is not resizable
- Do not get text selection when copy button cannot be used

* Update examples with latest changes

I also noticed an error when using the component in an uncontrolled way,
so I updated the examples and added a note on that regard.

* Upgrade react-ace to 5.9.0

* Remove hack to reset undo history

This was fixed upstream in
securingsincity/react-ace#345

* Fix copy and paste disabled state
  • Loading branch information
edmundoa authored and bernd committed Jan 11, 2018
1 parent e54ed6d commit 9931802
Show file tree
Hide file tree
Showing 7 changed files with 384 additions and 4 deletions.
2 changes: 2 additions & 0 deletions graylog2-web-interface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,13 @@
"numeral": "^1.5.3",
"opensans-npm-webfont": "^1.0.0",
"qs": "^6.3.0",
"react-ace": "^5.9.0",
"react-day-picker": "^5.0.0",
"react-dnd": "^2.0.2",
"react-dnd-html5-backend": "^2.0.0",
"react-grid-layout": "^0.14.3",
"react-overlays": "^0.6.5",
"react-resizable": "^1.7.5",
"react-select": "^v1.0.0-rc.10",
"rickshaw": "^1.5.1",
"sockjs-client": "1.1.x",
Expand Down
12 changes: 10 additions & 2 deletions graylog2-web-interface/src/components/common/ClipboardButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ const ClipboardButton = React.createClass({
bsStyle: PropTypes.string,
/** Button's bsSize. */
bsSize: PropTypes.string,
/** Specifies if the button is disabled or not. */
disabled: PropTypes.bool,
/** Text to display when hovering over the button. */
buttonTitle: PropTypes.string,
},
getDefaultProps() {
return {
action: 'copy',
disabled: false,
buttonTitle: undefined,
};
},
getInitialState() {
Expand Down Expand Up @@ -59,15 +65,17 @@ const ClipboardButton = React.createClass({
},
_onError(event) {
const key = event.action === 'cut' ? 'K' : 'C';
this.setState({ tooltipMessage: `Press Ctrl+${key} to ${event.action}` });
this.setState({ tooltipMessage: <span>Press Ctrl+{key}&thinsp;/&thinsp;&#8984;{key} to {event.action}</span> });
},
_getFilteredProps() {
const { className, style, bsStyle, bsSize } = this.props;
const { className, style, bsStyle, bsSize, disabled, buttonTitle } = this.props;
return {
className: className,
style: style,
bsStyle: bsStyle,
bsSize: bsSize,
disabled: disabled,
title: buttonTitle,
};
},
render() {
Expand Down
35 changes: 35 additions & 0 deletions graylog2-web-interface/src/components/common/SourceCodeEditor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
:local(.toolbar) {
background: #f8f8f8;
border: 1px solid #ccc;
border-bottom: 0;
border-radius: 5px 5px 0 0;
}

:local(.toolbar) .btn-link {
color: #333;
}

/* Do not add border radius if code editor comes after toolbar */
:local(.toolbar) + :local(.sourceCodeEditor) .ace_editor {
border-radius: 0 0 5px 5px;
}

:local(.sourceCodeEditor) .ace_editor {
border: 1px solid #ccc;
border-radius: 5px;
}

/* Ensure resize handle is over text editor */
:local(.sourceCodeEditor) .react-resizable-handle {
z-index: 100;
}

/* Make resize handle visible on a dark background */
:local(.darkMode) .react-resizable-handle {
filter: invert(100%) brightness(180%);
}

/* Hide resizable handle if editor is not resizable */
:local(.static) .react-resizable-handle {
display: none;
}
205 changes: 205 additions & 0 deletions graylog2-web-interface/src/components/common/SourceCodeEditor.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import React from 'react';
import { PropTypes } from 'prop-types';
import { Resizable } from 'react-resizable';
import 'brace';
import AceEditor from 'react-ace';
import { Button, ButtonGroup, ButtonToolbar, OverlayTrigger, Tooltip } from 'react-bootstrap';

import { ClipboardButton } from 'components/common';

import 'brace/mode/json';
import 'brace/mode/lua';
import 'brace/mode/markdown';
import 'brace/mode/text';
import 'brace/mode/yaml';
import 'brace/theme/tomorrow';
import 'brace/theme/monokai';
import style from './SourceCodeEditor.css';

/**
* Component that renders a source code editor input. This is what powers the pipeline rules and collector
* editors.
*
* **Note:** The component needs to be used in a [controlled way](https://reactjs.org/docs/forms.html#controlled-components).
* Letting the component handle its own internal state may lead to weird errors while typing.
*/
class SourceCodeEditor extends React.Component {
static propTypes = {
/**
* Annotations to show in the editor's gutter. The format should be:
* `[{ row: 0, column: 2, type: 'error', text: 'Some error.'}]`
* The type value must be one of `error`, `warning`, or `info`.
*/
annotations: PropTypes.array,
/** Specifies if the source code editor should have the input focus or not. */
focus: PropTypes.bool,
/** Specifies the font size in pixels to use in the text editor. */
fontSize: PropTypes.number,
/** Editor height in pixels. */
height: PropTypes.number,
/** Specifies a unique ID for the source code editor. */
id: PropTypes.string.isRequired,
/** Specifies the mode to use in the editor. This is used for highlighting and auto-completion. */
mode: PropTypes.oneOf(['json', 'lua', 'markdown', 'text', 'yaml']),
/** Function called on editor load. The first argument is the instance of the editor. */
onLoad: PropTypes.func,
/** Function called when the value of the text changes. It receives the the new value and an event as arguments. */
onChange: PropTypes.func,
/** Specifies if the editor should be in read-only mode. */
readOnly: PropTypes.bool,
/** Specifies if the editor should be resizable by the user. */
resizable: PropTypes.bool,
/** Specifies the theme to use for the editor. */
theme: PropTypes.oneOf(['light', 'dark']),
/** Specifies if the editor should also include a toolbar. */
toolbar: PropTypes.bool,
/** Text to use in the editor. */
value: PropTypes.string,
/** Editor width in pixels. Use `Infinity` to indicate the editor should use 100% of its container's width. */
width: PropTypes.number,
}

static defaultProps = {
annotations: [],
focus: false,
fontSize: 13,
height: 200,
mode: 'text',
onChange: () => {},
onLoad: () => {},
readOnly: false,
resizable: true,
theme: 'light',
toolbar: true,
value: '',
width: Infinity,
}

constructor(props) {
super(props);
this.state = {
height: props.height,
width: props.width,
selectedText: '',
};
}

componentDidUpdate(prevProps) {
if (this.props.height !== prevProps.height || this.props.width !== prevProps.width) {
this.reloadEditor();
}
}

handleResize = (event, { size }) => {
const { height, width } = size;
this.setState({ height: height, width: width }, this.reloadEditor);
}

reloadEditor = () => {
if (this.props.resizable) {
this.reactAce.editor.resize();
}
}

isCopyDisabled = () => this.props.readOnly || this.state.selectedText === '';

isPasteDisabled = () => this.props.readOnly;

isRedoDisabled = () => this.props.readOnly || !this.reactAce || !this.reactAce.editor.getSession().getUndoManager().hasRedo();

isUndoDisabled = () => this.props.readOnly || !this.reactAce || !this.reactAce.editor.getSession().getUndoManager().hasUndo();

handleRedo = () => {
this.reactAce.editor.redo();
this.focusEditor();
}

handleUndo = () => {
this.reactAce.editor.undo();
this.focusEditor();
}

handleSelectionChange = (selection) => {
if (!this.reactAce || !this.props.toolbar || this.props.readOnly) {
return;
}
const selectedText = this.reactAce.editor.getSession().getTextRange(selection.getRange());
this.setState({ selectedText: selectedText });
}

focusEditor = () => {
this.reactAce.editor.focus();
}

render() {
const { height, width } = this.state;
const { theme, resizable } = this.props;
const validCssWidth = Number.isNaN(width) ? '100%' : width;
const containerStyle = `${style.sourceCodeEditor} ${theme !== 'light' && style.darkMode} ${!resizable && style.static}`;
const overlay = <Tooltip id={'paste-button-tooltip'}>Press Ctrl+V (&#8984;V in macOS) or select Edit&thinsp;&rarr;&thinsp;Paste to paste from clipboard.</Tooltip>;
return (
<div>
{this.props.toolbar &&
<div className={style.toolbar} style={{ width: validCssWidth }}>
<ButtonToolbar>
<ButtonGroup>
<ClipboardButton title={<i className="fa fa-copy fa-fw" />}
bsStyle="link"
bsSize="sm"
onSuccess={this.focusEditor}
text={this.state.selectedText}
buttonTitle="Copy (Ctrl+C / &#8984;C)"
disabled={this.isCopyDisabled()} />
<OverlayTrigger placement="top" trigger="click" overlay={overlay} rootClose>
<Button bsStyle="link" bsSize="sm" title="Paste (Ctrl+V / &#8984;V)" disabled={this.isPasteDisabled()}>
<i className="fa fa-paste fa-fw" />
</Button>
</OverlayTrigger>
</ButtonGroup>
<ButtonGroup>
<Button bsStyle="link"
bsSize="sm"
onClick={this.handleUndo}
title="Undo (Ctrl+Z / &#8984;Z)"
disabled={this.isUndoDisabled()}>
<i className="fa fa-undo fa-fw" />
</Button>
<Button bsStyle="link"
bsSize="sm"
onClick={this.handleRedo}
title="Redo (Ctrl+Shift+Z / &#8984;&#8679;Z)"
disabled={this.isRedoDisabled()}>
<i className="fa fa-repeat fa-fw" />
</Button>
</ButtonGroup>
</ButtonToolbar>
</div>
}
<Resizable height={height}
width={width}
minConstraints={[200, 200]}
onResize={this.handleResize}>
<div className={containerStyle} style={{ height: height, width: validCssWidth }}>
<AceEditor ref={(c) => { this.reactAce = c; }}
annotations={this.props.annotations}
editorProps={{ $blockScrolling: 'Infinity' }}
focus={this.props.focus}
fontSize={this.props.fontSize}
mode={this.props.mode}
theme={this.props.theme === 'light' ? 'tomorrow' : 'monokai'}
name={this.props.id}
height="100%"
onLoad={this.props.onLoad}
onChange={this.props.onChange}
onSelectionChange={this.handleSelectionChange}
readOnly={this.props.readOnly}
value={this.props.value}
width="100%" />
</div>
</Resizable>
</div>
);
}
}

export default SourceCodeEditor;
Loading

0 comments on commit 9931802

Please sign in to comment.