diff --git a/package.json b/package.json
index 875352da6fa..967915a3e86 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,8 @@
"react-is": "~16.3.0",
"react-virtualized": "^9.21.2",
"resize-observer-polyfill": "^1.5.0",
+ "showdown": "^1.9.1",
+ "showdown-htmlescape": "^0.1.9",
"tabbable": "^3.0.0",
"uuid": "^3.1.0"
},
diff --git a/src-docs/src/i18ntokens.json b/src-docs/src/i18ntokens.json
index 446a147032f..c754f85d36c 100644
--- a/src-docs/src/i18ntokens.json
+++ b/src-docs/src/i18ntokens.json
@@ -1407,6 +1407,86 @@
},
"filepath": "src/components/link/link.tsx"
},
+ {
+ "token": "euiMarkdownEditor.boldStyles",
+ "defString": "Bold and italic styles",
+ "highlighting": "string",
+ "loc": {
+ "start": {
+ "line": 136,
+ "column": 16
+ },
+ "end": {
+ "line": 138,
+ "column": 51
+ }
+ },
+ "filepath": "src/components/markdown_editor/markdown_editor.tsx"
+ },
+ {
+ "token": "euiMarkdownEditor.quoteStyles",
+ "defString": "Quote, code and link styles",
+ "highlighting": "string",
+ "loc": {
+ "start": {
+ "line": 155,
+ "column": 16
+ },
+ "end": {
+ "line": 157,
+ "column": 56
+ }
+ },
+ "filepath": "src/components/markdown_editor/markdown_editor.tsx"
+ },
+ {
+ "token": "euiMarkdownEditor.listStyles",
+ "defString": "Unordered and ordered list styles",
+ "highlighting": "string",
+ "loc": {
+ "start": {
+ "line": 174,
+ "column": 16
+ },
+ "end": {
+ "line": 176,
+ "column": 62
+ }
+ },
+ "filepath": "src/components/markdown_editor/markdown_editor.tsx"
+ },
+ {
+ "token": "euiMarkdownEditor.edit",
+ "defString": "Edit",
+ "highlighting": "string",
+ "loc": {
+ "start": {
+ "line": 202,
+ "column": 20
+ },
+ "end": {
+ "line": 202,
+ "column": 77
+ }
+ },
+ "filepath": "src/components/markdown_editor/markdown_editor.tsx"
+ },
+ {
+ "token": "euiMarkdownEditor.previewMarkdown",
+ "defString": "Preview markdown",
+ "highlighting": "string",
+ "loc": {
+ "start": {
+ "line": 204,
+ "column": 20
+ },
+ "end": {
+ "line": 207,
+ "column": 22
+ }
+ },
+ "filepath": "src/components/markdown_editor/markdown_editor.tsx"
+ },
{
"token": "euiModal.closeModal",
"defString": "Closes this modal window",
diff --git a/src-docs/src/views/form_controls/form_controls_example.js b/src-docs/src/views/form_controls/form_controls_example.js
index 4c61e9ea9f4..1f69ee3dde1 100644
--- a/src-docs/src/views/form_controls/form_controls_example.js
+++ b/src-docs/src/views/form_controls/form_controls_example.js
@@ -20,6 +20,7 @@ import {
EuiFormControlLayout,
EuiFormControlLayoutDelimited,
EuiLink,
+ EuiMarkdownEditor,
EuiRadio,
EuiRadioGroup,
EuiSelect,
@@ -47,6 +48,10 @@ import TextArea from './text_area';
const textAreaSource = require('!!raw-loader!./text_area');
const textAreaHtml = renderToHtml(TextArea);
+import MarkdownEditor from './markdown_editor';
+const markdownEditorSource = require('!!raw-loader!./markdown_editor');
+const markdownEditorHtml = renderToHtml(MarkdownEditor);
+
import { FilePicker } from './file_picker';
const filePickerSource = require('!!raw-loader!./file_picker');
const filePickerHtml = renderToHtml(FilePicker);
@@ -203,6 +208,29 @@ export const FormControlsExample = {
},
demo: ,
},
+ {
+ title: 'Markdown Editor',
+ source: [
+ {
+ type: GuideSectionTypes.JS,
+ code: markdownEditorSource,
+ },
+ {
+ type: GuideSectionTypes.HTML,
+ code: markdownEditorHtml,
+ },
+ ],
+ text: (
+
+ This component renders a markdown editor, including buttons for
+ quickly inserting common markdown elements and a preview mode.
+
+ ),
+ props: {
+ EuiMarkdownEditor,
+ },
+ demo: ,
+ },
{
title: 'File Picker',
source: [
diff --git a/src-docs/src/views/form_controls/markdown_editor.js b/src-docs/src/views/form_controls/markdown_editor.js
new file mode 100644
index 00000000000..49e6db23980
--- /dev/null
+++ b/src-docs/src/views/form_controls/markdown_editor.js
@@ -0,0 +1,10 @@
+/* eslint-disable prettier/prettier */
+import React from 'react';
+
+import {
+ EuiMarkdownEditor,
+} from '../../../../src/components/markdown_editor';
+
+export default () => (
+
+);
diff --git a/src/components/index.js b/src/components/index.js
index febbba31948..5557d2d5460 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -175,6 +175,8 @@ export { EuiLink } from './link';
export { EuiListGroup, EuiListGroupItem } from './list_group';
+export { EuiMarkdownEditor } from './markdown_editor';
+
export {
EUI_MODAL_CANCEL_BUTTON,
EUI_MODAL_CONFIRM_BUTTON,
diff --git a/src/components/index.scss b/src/components/index.scss
index 57f885deb08..533857f46ef 100644
--- a/src/components/index.scss
+++ b/src/components/index.scss
@@ -39,6 +39,7 @@
@import 'link/index';
@import 'list_group/index';
@import 'loading/index';
+@import 'markdown_editor/index';
@import 'modal/index';
@import 'nav_drawer/index';
@import 'overlay_mask/index';
diff --git a/src/components/markdown_editor/_index.scss b/src/components/markdown_editor/_index.scss
new file mode 100644
index 00000000000..4d913d41a98
--- /dev/null
+++ b/src/components/markdown_editor/_index.scss
@@ -0,0 +1 @@
+@import 'markdown_editor';
diff --git a/src/components/markdown_editor/_markdown_editor.scss b/src/components/markdown_editor/_markdown_editor.scss
new file mode 100644
index 00000000000..fc61607fd4e
--- /dev/null
+++ b/src/components/markdown_editor/_markdown_editor.scss
@@ -0,0 +1,40 @@
+.euiMarkdownEditor__actionBar {
+ background: $euiColorEmptyShade;
+ border: $euiBorderThin;
+ border-color: $euiColorLightShade;
+ border-bottom: none;
+ padding: 0 ($euiSizeS + $euiSizeXS);
+}
+
+.euiMarkdownEditor__actionBarItems {
+ padding: $euiSizeS 0;
+}
+
+.euiMarkdownEditor__previewToggleButton {
+ width: 150px;
+}
+
+.euiMarkdownEditor__previewContainer {
+ border: $euiBorderThin;
+ max-height: 150px;
+ overflow-y: auto;
+ @include euiScrollBar;
+ padding: $euiSizeM;
+}
+
+// Overrides for styles in the "preview" pane
+
+.euiMarkdownEditor__markdownWrapper .euiMarkdownEditor__displayText {
+ blockquote {
+ @include euiFont;
+ border-left: $euiBorderThick;
+ color: $euiColorDarkShade;
+ font-style: normal;
+ padding: 0 $euiSize;
+ text-align: left;
+
+ &::before, &::after {
+ display: none;
+ }
+ }
+}
diff --git a/src/components/markdown_editor/index.ts b/src/components/markdown_editor/index.ts
new file mode 100644
index 00000000000..5571dd3f6d3
--- /dev/null
+++ b/src/components/markdown_editor/index.ts
@@ -0,0 +1 @@
+export { EuiMarkdownEditor } from './markdown_editor';
diff --git a/src/components/markdown_editor/markdown_actions.ts b/src/components/markdown_editor/markdown_actions.ts
new file mode 100644
index 00000000000..457efd6d28e
--- /dev/null
+++ b/src/components/markdown_editor/markdown_actions.ts
@@ -0,0 +1,523 @@
+interface StyleArgsToUpdate {
+ prefix?: string;
+ suffix?: string;
+ blockPrefix?: string;
+ blockSuffix?: string;
+ multiline?: boolean;
+ replaceNext?: string;
+ prefixSpace?: boolean;
+ scanFor?: string;
+ surroundWithNewlines?: boolean;
+ orderedList?: boolean;
+ trimFirst?: boolean;
+}
+
+/**
+ * Class for applying styles to a text editor. Accepts the HTML ID for the textarea
+ * desired, and exposes the `.do(ACTION)` method for manipulating the text.
+ *
+ * @class MarkdownActions
+ * @param {string} editorID
+ */
+class MarkdownActions {
+ editorID: string;
+ styles: Record;
+
+ constructor(editorID: string) {
+ this.editorID = editorID;
+
+ /**
+ * This object is in the format:
+ * [nameOfAction]: {[styles to apply]}
+ */
+ this.styles = {
+ mdBold: {
+ prefix: '**',
+ suffix: '**',
+ trimFirst: true,
+ },
+ mdItalic: {
+ prefix: '_',
+ suffix: '_',
+ trimFirst: true,
+ },
+ mdQuote: {
+ prefix: '> ',
+ multiline: true,
+ surroundWithNewlines: true,
+ },
+ mdCode: {
+ prefix: '`',
+ suffix: '`',
+ blockPrefix: '```',
+ blockSuffix: '```',
+ },
+ mdLink: {
+ prefix: '[',
+ suffix: '](url)',
+ replaceNext: 'url',
+ scanFor: 'https?://',
+ },
+ mdUl: {
+ prefix: '- ',
+ multiline: true,
+ surroundWithNewlines: true,
+ },
+ mdOl: {
+ prefix: '1. ',
+ multiline: true,
+ orderedList: true,
+ },
+ };
+ }
+
+ /**
+ * .do() accepts a string and retrieves the correlating style object (defined in the
+ * constructor). It passes this to applyStyle() that does the text manipulation.
+ *
+ * @param {string} action
+ * @memberof MarkdownActions
+ */
+ do(action: string) {
+ this.applyStyle(this.styles[action]);
+ }
+
+ /**
+ * Sets the default styling object and then superimposes the changes to make on top of
+ * it. Calls the `styleSelectedText` helper function that does the heavy lifting.
+ *
+ * @param {object} incomingStyle
+ * @memberof MarkdownActions
+ */
+ applyStyle(incomingStyle: object) {
+ const defaults = {
+ prefix: '',
+ suffix: '',
+ blockPrefix: '',
+ blockSuffix: '',
+ multiline: false,
+ replaceNext: '',
+ prefixSpace: false,
+ scanFor: '',
+ surroundWithNewlines: false,
+ orderedList: false,
+ trimFirst: false,
+ };
+
+ const outgoingStyle = {
+ ...defaults,
+ ...incomingStyle,
+ };
+ const editor = document.getElementById(this.editorID);
+
+ if (editor) {
+ editor.focus();
+ // @ts-ignore
+ styleSelectedText(editor, outgoingStyle);
+ }
+ }
+}
+
+/**
+ * The following helper functions and types were copied from the GitHub Markdown Toolbar
+ * Element project. The project is MIT-licensed. See it here:
+ * https://github.com/github/markdown-toolbar-element
+ */
+
+interface Newlines {
+ newlinesToAppend: string;
+ newlinesToPrepend: string;
+}
+
+interface SelectionRange {
+ text: string;
+ selectionStart?: number;
+ selectionEnd?: number;
+}
+
+interface StyleArgs {
+ prefix: string;
+ suffix: string;
+ blockPrefix: string;
+ blockSuffix: string;
+ multiline: boolean;
+ replaceNext: string;
+ prefixSpace: boolean;
+ scanFor: string;
+ surroundWithNewlines: boolean;
+ orderedList: boolean;
+ trimFirst: boolean;
+}
+
+function isMultipleLines(string: string): boolean {
+ return string.trim().split('\n').length > 1;
+}
+
+function repeat(string: string, n: number): string {
+ return Array(n + 1).join(string);
+}
+
+function wordSelectionStart(text: string, i: number): number {
+ let index = i;
+ while (
+ text[index] &&
+ text[index - 1] != null &&
+ !text[index - 1].match(/\s/)
+ ) {
+ index--;
+ }
+ return index;
+}
+
+function wordSelectionEnd(text: string, i: number, multiline: boolean): number {
+ let index = i;
+ const breakpoint = multiline ? /\n/ : /\s/;
+ while (text[index] && !text[index].match(breakpoint)) {
+ index++;
+ }
+ return index;
+}
+
+let canInsertText: boolean | null = null;
+
+/**
+ * Note that we're using the native HTMLTextAreaElement.set() method to play nicely with
+ * React's synthetic event system. We fallback to a brute force way of doing it if the
+ * above doesn't work. Although all modern browsers, including IE, seem to be fine:
+ * https://hustle.bizongo.in/simulate-react-on-change-on-controlled-components-baa336920e04
+ */
+function insertText(
+ textarea: HTMLTextAreaElement,
+ { text, selectionStart, selectionEnd }: SelectionRange
+) {
+ const originalSelectionStart = textarea.selectionStart;
+ const before = textarea.value.slice(0, originalSelectionStart);
+ const after = textarea.value.slice(textarea.selectionEnd);
+ const inputEvent = new Event('input', { bubbles: true });
+
+ if (canInsertText === null || canInsertText === true) {
+ canInsertText = true;
+
+ const nativeInputValueSetter =
+ // @ts-ignore
+ Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
+ try {
+ // @ts-ignore
+ nativeInputValueSetter.call(textarea, before + text + after);
+
+ textarea.dispatchEvent(inputEvent);
+ } catch (error) {
+ canInsertText = false;
+ }
+ }
+
+ if (!canInsertText) {
+ // If calling [HTMLTextAreaElement.set()] fails, just brute-force it
+ textarea.value = before + text + after;
+ textarea.dispatchEvent(inputEvent);
+ }
+
+ if (selectionStart != null && selectionEnd != null) {
+ textarea.setSelectionRange(selectionStart, selectionEnd);
+ } else {
+ textarea.setSelectionRange(originalSelectionStart, textarea.selectionEnd);
+ }
+}
+
+function styleSelectedText(
+ textarea: HTMLTextAreaElement,
+ styleArgs: StyleArgs
+) {
+ const text = textarea.value.slice(
+ textarea.selectionStart,
+ textarea.selectionEnd
+ );
+
+ let result;
+ if (styleArgs.orderedList) {
+ result = orderedList(textarea);
+ } else if (styleArgs.multiline && isMultipleLines(text)) {
+ result = multilineStyle(textarea, styleArgs);
+ } else {
+ result = blockStyle(textarea, styleArgs);
+ }
+
+ insertText(textarea, result);
+}
+
+function expandSelectedText(
+ textarea: HTMLTextAreaElement,
+ prefixToUse: string,
+ suffixToUse: string,
+ multiline: boolean = false
+): string {
+ if (textarea.selectionStart === textarea.selectionEnd) {
+ textarea.selectionStart = wordSelectionStart(
+ textarea.value,
+ textarea.selectionStart
+ );
+ textarea.selectionEnd = wordSelectionEnd(
+ textarea.value,
+ textarea.selectionEnd,
+ multiline
+ );
+ } else {
+ const expandedSelectionStart = textarea.selectionStart - prefixToUse.length;
+ const expandedSelectionEnd = textarea.selectionEnd + suffixToUse.length;
+ const beginsWithPrefix =
+ textarea.value.slice(expandedSelectionStart, textarea.selectionStart) ===
+ prefixToUse;
+ const endsWithSuffix =
+ textarea.value.slice(textarea.selectionEnd, expandedSelectionEnd) ===
+ suffixToUse;
+ if (beginsWithPrefix && endsWithSuffix) {
+ textarea.selectionStart = expandedSelectionStart;
+ textarea.selectionEnd = expandedSelectionEnd;
+ }
+ }
+ return textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
+}
+
+function newlinesToSurroundSelectedText(
+ textarea: HTMLTextAreaElement
+): Newlines {
+ const beforeSelection = textarea.value.slice(0, textarea.selectionStart);
+ const afterSelection = textarea.value.slice(textarea.selectionEnd);
+
+ const breaksBefore = beforeSelection.match(/\n*$/);
+ const breaksAfter = afterSelection.match(/^\n*/);
+ const newlinesBeforeSelection = breaksBefore ? breaksBefore[0].length : 0;
+ const newlinesAfterSelection = breaksAfter ? breaksAfter[0].length : 0;
+
+ let newlinesToAppend;
+ let newlinesToPrepend;
+
+ if (beforeSelection.match(/\S/) && newlinesBeforeSelection < 2) {
+ newlinesToAppend = repeat('\n', 2 - newlinesBeforeSelection);
+ }
+
+ if (afterSelection.match(/\S/) && newlinesAfterSelection < 2) {
+ newlinesToPrepend = repeat('\n', 2 - newlinesAfterSelection);
+ }
+
+ if (newlinesToAppend == null) {
+ newlinesToAppend = '';
+ }
+
+ if (newlinesToPrepend == null) {
+ newlinesToPrepend = '';
+ }
+
+ return { newlinesToAppend, newlinesToPrepend };
+}
+
+function blockStyle(
+ textarea: HTMLTextAreaElement,
+ arg: StyleArgs
+): SelectionRange {
+ let newlinesToAppend;
+ let newlinesToPrepend;
+
+ const {
+ prefix,
+ suffix,
+ blockPrefix,
+ blockSuffix,
+ replaceNext,
+ prefixSpace,
+ scanFor,
+ surroundWithNewlines,
+ } = arg;
+ const originalSelectionStart = textarea.selectionStart;
+ const originalSelectionEnd = textarea.selectionEnd;
+
+ let selectedText = textarea.value.slice(
+ textarea.selectionStart,
+ textarea.selectionEnd
+ );
+ let prefixToUse =
+ isMultipleLines(selectedText) && blockPrefix.length > 0
+ ? `${blockPrefix}\n`
+ : prefix;
+ let suffixToUse =
+ isMultipleLines(selectedText) && blockSuffix.length > 0
+ ? `\n${blockSuffix}`
+ : suffix;
+
+ if (prefixSpace) {
+ const beforeSelection = textarea.value[textarea.selectionStart - 1];
+ if (
+ textarea.selectionStart !== 0 &&
+ beforeSelection != null &&
+ !beforeSelection.match(/\s/)
+ ) {
+ prefixToUse = ` ${prefixToUse}`;
+ }
+ }
+ selectedText = expandSelectedText(
+ textarea,
+ prefixToUse,
+ suffixToUse,
+ arg.multiline
+ );
+ let selectionStart = textarea.selectionStart;
+ let selectionEnd = textarea.selectionEnd;
+ const hasReplaceNext =
+ replaceNext.length > 0 &&
+ suffixToUse.indexOf(replaceNext) > -1 &&
+ selectedText.length > 0;
+ if (surroundWithNewlines) {
+ const ref = newlinesToSurroundSelectedText(textarea);
+ newlinesToAppend = ref.newlinesToAppend;
+ newlinesToPrepend = ref.newlinesToPrepend;
+ prefixToUse = newlinesToAppend + prefix;
+ suffixToUse += newlinesToPrepend;
+ }
+
+ if (
+ selectedText.startsWith(prefixToUse) &&
+ selectedText.endsWith(suffixToUse)
+ ) {
+ const replacementText = selectedText.slice(
+ prefixToUse.length,
+ selectedText.length - suffixToUse.length
+ );
+ if (originalSelectionStart === originalSelectionEnd) {
+ let position = originalSelectionStart - prefixToUse.length;
+ position = Math.max(position, selectionStart);
+ position = Math.min(position, selectionStart + replacementText.length);
+ selectionStart = selectionEnd = position;
+ } else {
+ selectionEnd = selectionStart + replacementText.length;
+ }
+ return { text: replacementText, selectionStart, selectionEnd };
+ } else if (!hasReplaceNext) {
+ let replacementText = prefixToUse + selectedText + suffixToUse;
+ selectionStart = originalSelectionStart + prefixToUse.length;
+ selectionEnd = originalSelectionEnd + prefixToUse.length;
+ const whitespaceEdges = selectedText.match(/^\s*|\s*$/g);
+ if (arg.trimFirst && whitespaceEdges) {
+ const leadingWhitespace = whitespaceEdges[0] || '';
+ const trailingWhitespace = whitespaceEdges[1] || '';
+ replacementText =
+ leadingWhitespace +
+ prefixToUse +
+ selectedText.trim() +
+ suffixToUse +
+ trailingWhitespace;
+ selectionStart += leadingWhitespace.length;
+ selectionEnd -= trailingWhitespace.length;
+ }
+ return { text: replacementText, selectionStart, selectionEnd };
+ } else if (scanFor.length > 0 && selectedText.match(scanFor)) {
+ suffixToUse = suffixToUse.replace(replaceNext, selectedText);
+ const replacementText = prefixToUse + suffixToUse;
+ selectionStart = selectionEnd = selectionStart + prefixToUse.length;
+ return { text: replacementText, selectionStart, selectionEnd };
+ } else {
+ const replacementText = prefixToUse + selectedText + suffixToUse;
+ selectionStart =
+ selectionStart +
+ prefixToUse.length +
+ selectedText.length +
+ suffixToUse.indexOf(replaceNext);
+ selectionEnd = selectionStart + replaceNext.length;
+ return { text: replacementText, selectionStart, selectionEnd };
+ }
+}
+
+function multilineStyle(textarea: HTMLTextAreaElement, arg: StyleArgs) {
+ const { prefix, suffix, surroundWithNewlines } = arg;
+ let text = textarea.value.slice(
+ textarea.selectionStart,
+ textarea.selectionEnd
+ );
+ let selectionStart = textarea.selectionStart;
+ let selectionEnd = textarea.selectionEnd;
+ const lines = text.split('\n');
+ const undoStyle = lines.every(
+ line => line.startsWith(prefix) && line.endsWith(suffix)
+ );
+
+ if (undoStyle) {
+ text = lines
+ .map(line => line.slice(prefix.length, line.length - suffix.length))
+ .join('\n');
+ selectionEnd = selectionStart + text.length;
+ } else {
+ text = lines.map(line => prefix + line + suffix).join('\n');
+ if (surroundWithNewlines) {
+ const {
+ newlinesToAppend,
+ newlinesToPrepend,
+ } = newlinesToSurroundSelectedText(textarea);
+ selectionStart += newlinesToAppend.length;
+ selectionEnd = selectionStart + text.length;
+ text = newlinesToAppend + text + newlinesToPrepend;
+ }
+ }
+
+ return { text, selectionStart, selectionEnd };
+}
+
+function orderedList(textarea: HTMLTextAreaElement): SelectionRange {
+ const orderedListRegex = /^\d+\.\s+/;
+ const noInitialSelection = textarea.selectionStart === textarea.selectionEnd;
+ let selectionEnd;
+ let selectionStart;
+ let text = textarea.value.slice(
+ textarea.selectionStart,
+ textarea.selectionEnd
+ );
+ let textToUnstyle = text;
+ let lines = text.split('\n');
+ let startOfLine;
+ let endOfLine;
+ if (noInitialSelection) {
+ const linesBefore = textarea.value
+ .slice(0, textarea.selectionStart)
+ .split(/\n/);
+ startOfLine =
+ textarea.selectionStart - linesBefore[linesBefore.length - 1].length;
+ endOfLine = wordSelectionEnd(textarea.value, textarea.selectionStart, true);
+ textToUnstyle = textarea.value.slice(startOfLine, endOfLine);
+ }
+ const linesToUnstyle = textToUnstyle.split('\n');
+ const undoStyling = linesToUnstyle.every(line => orderedListRegex.test(line));
+
+ if (undoStyling) {
+ lines = linesToUnstyle.map(line => line.replace(orderedListRegex, ''));
+ text = lines.join('\n');
+ if (noInitialSelection && startOfLine && endOfLine) {
+ const lengthDiff = linesToUnstyle[0].length - lines[0].length;
+ selectionStart = selectionEnd = textarea.selectionStart - lengthDiff;
+ textarea.selectionStart = startOfLine;
+ textarea.selectionEnd = endOfLine;
+ }
+ } else {
+ lines = (function() {
+ let i;
+ let len;
+ let index;
+ const results = [];
+ for (index = i = 0, len = lines.length; i < len; index = ++i) {
+ const line = lines[index];
+ results.push(`${index + 1}. ${line}`);
+ }
+ return results;
+ })();
+ text = lines.join('\n');
+ const {
+ newlinesToAppend,
+ newlinesToPrepend,
+ } = newlinesToSurroundSelectedText(textarea);
+ selectionStart = textarea.selectionStart + newlinesToAppend.length;
+ selectionEnd = selectionStart + text.length;
+ if (noInitialSelection) selectionStart = selectionEnd;
+ text = newlinesToAppend + text + newlinesToPrepend;
+ }
+
+ return { text, selectionStart, selectionEnd };
+}
+
+export default MarkdownActions;
diff --git a/src/components/markdown_editor/markdown_editor.test.tsx b/src/components/markdown_editor/markdown_editor.test.tsx
new file mode 100644
index 00000000000..a6a889be520
--- /dev/null
+++ b/src/components/markdown_editor/markdown_editor.test.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { render } from 'enzyme';
+import { requiredProps } from '../../test/required_props';
+
+import { EuiMarkdownEditor } from './markdown_editor';
+
+describe('EuiMarkdownEditor', () => {
+ test('is rendered', () => {
+ const component = render();
+
+ expect(component).toMatchSnapshot();
+ });
+});
diff --git a/src/components/markdown_editor/markdown_editor.tsx b/src/components/markdown_editor/markdown_editor.tsx
new file mode 100644
index 00000000000..055bcc13185
--- /dev/null
+++ b/src/components/markdown_editor/markdown_editor.tsx
@@ -0,0 +1,241 @@
+import React, { Component, HTMLAttributes } from 'react';
+import classNames from 'classnames';
+import { CommonProps } from '../common';
+import MarkdownActions from './markdown_actions';
+
+// @ts-ignore
+import showdown from 'showdown';
+// @ts-ignore
+import showdownHtmlEscape from 'showdown-htmlescape';
+
+import { EuiHideFor } from '../responsive';
+import { EuiText } from '../text';
+// @ts-ignore
+import { EuiTextArea } from '../form/text_area';
+import { EuiButtonGroup, EuiButtonEmpty } from '../button';
+import { EuiFlexItem, EuiFlexGroup } from '../flex';
+import { EuiI18n } from '../i18n';
+
+export type EuiMarkdownEditorProps = HTMLAttributes &
+ CommonProps & {
+ /** A unique ID to attach to the textarea. If one isn't provided, a random one
+ * will be generated */
+ editorID?: string;
+ };
+
+export interface MarkdownEditorState {
+ editorContent: string;
+ viewMarkdownPreview: boolean;
+}
+
+export class EuiMarkdownEditor extends Component<
+ EuiMarkdownEditorProps,
+ MarkdownEditorState
+> {
+ converter: showdown.Converter;
+ editorID: string;
+ markdownActions: MarkdownActions;
+
+ constructor(props: EuiMarkdownEditorProps) {
+ super(props);
+
+ // Instantiate Showdown (for converting markdown -> html) with options
+ this.converter = new showdown.Converter({
+ openLinksInNewWindow: true,
+ ghMentions: false,
+ backslashEscapesHTMLTags: true,
+ extensions: [showdownHtmlEscape],
+ });
+ this.converter.setFlavor('github');
+
+ this.state = {
+ editorContent: '',
+ viewMarkdownPreview: false,
+ };
+
+ // If an ID wasn't provided, just generate a rando
+ this.editorID =
+ this.props.editorID ||
+ Math.random()
+ .toString(35)
+ .substring(2, 10);
+ this.markdownActions = new MarkdownActions(this.editorID);
+
+ this.handleMdButtonClick = this.handleMdButtonClick.bind(this);
+ }
+
+ getBoldItalicButtons = () => [
+ {
+ id: 'mdBold',
+ label: 'Bold',
+ name: 'bold',
+ iconType: 'editorBold',
+ },
+ {
+ id: 'mdItalic',
+ label: 'Italic',
+ name: 'italic',
+ iconType: 'editorItalic',
+ },
+ ];
+
+ getQuoteCodeLinkButtons = () => [
+ {
+ id: 'mdQuote',
+ label: 'Quote',
+ name: 'quote',
+ iconType: 'editorComment',
+ },
+ {
+ id: 'mdCode',
+ label: 'Code',
+ name: 'code',
+ iconType: 'editorCodeBlock',
+ },
+ {
+ id: 'mdLink',
+ label: 'Link',
+ name: 'link',
+ iconType: 'editorLink',
+ },
+ ];
+
+ getListButtons = () => [
+ {
+ id: 'mdUl',
+ label: 'Unordered list',
+ name: 'ul',
+ iconType: 'editorUnorderedList',
+ },
+ {
+ id: 'mdOl',
+ label: 'Ordered list',
+ name: 'ol',
+ iconType: 'editorOrderedList',
+ },
+ ];
+
+ handleMdButtonClick = (mdButtonId: string) => {
+ this.markdownActions.do(mdButtonId);
+ };
+
+ render() {
+ const { className, ...rest } = this.props;
+
+ const classes = classNames('euiMarkdownEditor', className);
+
+ return (
+
+
+
+
+
+
+ {(legend: string) => (
+
+ )}
+
+
+
+
+ {(legend: string) => (
+
+ )}
+
+
+
+
+ {(legend: string) => (
+
+ )}
+
+
+
+ {
+ this.setState({
+ viewMarkdownPreview: !this.state.viewMarkdownPreview,
+ });
+ }}>
+ {this.state.viewMarkdownPreview ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {this.state.viewMarkdownPreview ? (
+
+ ) : (
+
+ {
+ this.setState({ editorContent: e.target.value });
+ }}
+ value={this.state.editorContent}
+ />
+
+ )}
+
+ );
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index dc142e9cf19..404e5d45424 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3249,6 +3249,15 @@ cliui@^4.0.0:
strip-ansi "^4.0.0"
wrap-ansi "^2.0.0"
+cliui@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
+ integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
+ dependencies:
+ string-width "^3.1.0"
+ strip-ansi "^5.2.0"
+ wrap-ansi "^5.1.0"
+
clone-buffer@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
@@ -4291,7 +4300,7 @@ debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
dependencies:
ms "^2.1.1"
-decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2:
+decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@@ -6316,6 +6325,11 @@ get-caller-file@^1.0.1:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.2.tgz#f702e63127e7e231c160a80c1554acb70d5047e5"
integrity sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=
+get-caller-file@^2.0.1:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+ integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+
get-func-name@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
@@ -12623,6 +12637,11 @@ require-main-filename@^1.0.1:
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"
integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=
+require-main-filename@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
+ integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
+
require-uncached@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3"
@@ -13277,6 +13296,20 @@ shellwords@^0.1.1:
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
+showdown-htmlescape@^0.1.9:
+ version "0.1.9"
+ resolved "https://registry.yarnpkg.com/showdown-htmlescape/-/showdown-htmlescape-0.1.9.tgz#4299676a209016c9fbdabe60c38408eef3eb1800"
+ integrity sha512-kkg0Lh6CRzJKMvIkNBYNkZaJiZSii98mfiZj3FpQK0lXn1iV0UT5VD63YoTAqOCP7QiIo3nTH/z5ndVKCfMenQ==
+ dependencies:
+ showdown "^1.2.3"
+
+showdown@^1.2.3, showdown@^1.9.1:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/showdown/-/showdown-1.9.1.tgz#134e148e75cd4623e09c21b0511977d79b5ad0ef"
+ integrity sha512-9cGuS382HcvExtf5AHk7Cb4pAeQQ+h0eTr33V1mu+crYWV4KvWAw6el92bDrqGEk5d46Ai/fhbEUwqJ/mTCNEA==
+ dependencies:
+ yargs "^14.2"
+
signal-exit@^3.0.0, signal-exit@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
@@ -13694,7 +13727,7 @@ string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
is-fullwidth-code-point "^2.0.0"
strip-ansi "^4.0.0"
-string-width@^3.0.0:
+string-width@^3.0.0, string-width@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
@@ -13759,7 +13792,7 @@ strip-ansi@^5.0.0:
dependencies:
ansi-regex "^4.0.0"
-strip-ansi@^5.1.0:
+strip-ansi@^5.1.0, strip-ansi@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
@@ -15294,6 +15327,15 @@ wrap-ansi@^2.0.0:
string-width "^1.0.1"
strip-ansi "^3.0.1"
+wrap-ansi@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
+ integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
+ dependencies:
+ ansi-styles "^3.2.0"
+ string-width "^3.0.0"
+ strip-ansi "^5.0.0"
+
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@@ -15441,6 +15483,14 @@ yargs-parser@^10.1.0:
dependencies:
camelcase "^4.1.0"
+yargs-parser@^15.0.0:
+ version "15.0.0"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.0.tgz#cdd7a97490ec836195f59f3f4dbe5ea9e8f75f08"
+ integrity sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ==
+ dependencies:
+ camelcase "^5.0.0"
+ decamelize "^1.2.0"
+
yargs-parser@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a"
@@ -15473,6 +15523,23 @@ yargs@12.0.2, yargs@^12.0.2:
y18n "^3.2.1 || ^4.0.0"
yargs-parser "^10.1.0"
+yargs@^14.2:
+ version "14.2.2"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.2.tgz#2769564379009ff8597cdd38fba09da9b493c4b5"
+ integrity sha512-/4ld+4VV5RnrynMhPZJ/ZpOCGSCeghMykZ3BhdFBDa9Wy/RH6uEGNWDJog+aUlq+9OM1CFTgtYRW5Is1Po9NOA==
+ dependencies:
+ cliui "^5.0.0"
+ decamelize "^1.2.0"
+ find-up "^3.0.0"
+ get-caller-file "^2.0.1"
+ require-directory "^2.1.1"
+ require-main-filename "^2.0.0"
+ set-blocking "^2.0.0"
+ string-width "^3.0.0"
+ which-module "^2.0.0"
+ y18n "^4.0.0"
+ yargs-parser "^15.0.0"
+
yargs@^7.0.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8"