Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDouwe Maan <douwe@selenight.nl>2019-02-03 18:45:13 +0300
committerDouwe Maan <douwe@selenight.nl>2019-02-05 21:34:15 +0300
commit6b48e9cbbd8d0d60518a36eb356fffca09ad0869 (patch)
tree25c57d64390e9ef6b7b4c16d05b4c776bb0b2e86
parent71ba26e78f41bea7b0bba088c1c6133923e61039 (diff)
WIP: Add Rich WYSIWYG editor to Markdown fieldsdm-rich-markdown-editor
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_diff.js12
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_html.js16
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/math.js9
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/strike.js5
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/blockquote.js5
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/code_block.js14
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js9
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js9
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/paragraph.js5
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list.js9
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js30
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue228
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue28
-rw-r--r--app/assets/javascripts/zen_mode.js2
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss30
-rw-r--r--app/assets/stylesheets/framework/typography.scss42
-rw-r--r--app/assets/stylesheets/framework/zen.scss2
-rw-r--r--app/assets/stylesheets/pages/note_form.scss3
-rw-r--r--package.json2
21 files changed, 441 insertions, 37 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
index 47e5fc65c48..d224694a695 100644
--- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -100,7 +100,7 @@ export default [
new InlineDiff(),
new Link(),
- new Code(),
new MathMark(),
+ new Code(),
new InlineHTML(),
];
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
index ce425e80cd3..ac06a4eec6f 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
+import { toggleMark, markInputRule } from 'tiptap-commands';
// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter
export default class InlineDiff extends Mark {
@@ -38,4 +39,15 @@ export default class InlineDiff extends Mark {
},
};
}
+
+ commands({ type }) {
+ return () => toggleMark(type);
+ }
+
+ inputRules({ type }) {
+ return [
+ markInputRule(/(?:\[\+|\{\+)([^+]+)(?:\+\]|\+\})$/, type, { addition: true }),
+ markInputRule(/(?:\[-|\{-)([^-]+)(?:-\]|-\})$/, type, { addition: false }),
+ ];
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
index ebed8698e21..3fd585128e3 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
@@ -1,8 +1,11 @@
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
+import { toggleMark, markInputRule } from 'tiptap-commands';
import _ from 'underscore';
+const tags = ['sup', 'sub', 'kbd', 'q', 'samp', 'var', 'abbr'];
+
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class InlineHTML extends Mark {
get name() {
@@ -18,11 +21,12 @@ export default class InlineHTML extends Mark {
},
parseDOM: [
{
- tag: 'sup, sub, kbd, q, samp, var',
+ tag: tags.join(', '),
getAttrs: el => ({ tag: el.nodeName.toLowerCase() }),
},
{
tag: 'abbr',
+ priority: 51,
getAttrs: el => ({ tag: 'abbr', title: el.getAttribute('title') }),
},
],
@@ -43,4 +47,14 @@ export default class InlineHTML extends Mark {
},
};
}
+
+ commands({ type }) {
+ return () => toggleMark(type);
+ }
+
+ inputRules({ type }) {
+ return tags.map(tag =>
+ markInputRule(new RegExp(`(?:\\<${tag}\\>)([^\\<]+)(?:\\<\\/${tag}\\>)$`), type, { tag }),
+ );
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js
index e582fb18f15..7062ceff65c 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/math.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/math.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
+import { toggleMark, markInputRule } from 'tiptap-commands';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter
@@ -38,4 +39,12 @@ export default class MathMark extends Mark {
},
};
}
+
+ commands({ type }) {
+ return () => toggleMark(type);
+ }
+
+ inputRules({ type }) {
+ return [markInputRule(/(?:\$`)([^`]+)(?:`)$/, type)];
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js
index c2951a40a4b..6b51731e806 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/strike.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Strike as BaseStrike } from 'tiptap-extensions';
+import { markInputRule } from 'tiptap-commands';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class Strike extends BaseStrike {
@@ -12,4 +13,8 @@ export default class Strike extends BaseStrike {
expelEnclosingWhitespace: true,
};
}
+
+ inputRules({ type }) {
+ return [markInputRule(/~~([^~]+)~~$/, type)];
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
index b0bc8f79643..6469ee78d71 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Blockquote as BaseBlockquote } from 'tiptap-extensions';
+import { wrappingInputRule } from 'tiptap-commands';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
@@ -10,4 +11,8 @@ export default class Blockquote extends BaseBlockquote {
defaultMarkdownSerializer.nodes.blockquote(state, node);
}
+
+ inputRules({ type }) {
+ return [wrappingInputRule(/^\s*>\s$/, type), wrappingInputRule(/^>>>$/, type)];
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
index 1e0c05eff08..97a9981293a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions';
+import { toggleBlockType } from 'tiptap-commands';
const PLAINTEXT_LANG = 'plaintext';
@@ -68,7 +69,14 @@ export default class CodeBlock extends BaseCodeBlock {
attrs: { lang: 'suggestion' },
},
],
- toDOM: node => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
+ toDOM: node => [
+ 'pre',
+ {
+ class: `code highlight ${node.attrs.lang} ${gon.user_color_scheme}`,
+ lang: node.attrs.lang,
+ },
+ ['code', 0],
+ ],
};
}
@@ -96,4 +104,8 @@ export default class CodeBlock extends BaseCodeBlock {
state.write('```');
state.closeBlock(node);
}
+
+ commands({ type, schema }) {
+ return attrs => toggleBlockType(type, schema.nodes.paragraph, attrs);
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
index 695c7160bde..219b077f44a 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions';
+import { InputRule } from 'prosemirror-inputrules';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
@@ -8,4 +9,12 @@ export default class HorizontalRule extends BaseHorizontalRule {
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.horizontal_rule(state, node);
}
+
+ inputRules({ type }) {
+ return [
+ new InputRule(/^---$/, (state, match, start, end) =>
+ state.tr.delete(start, end).insert(start - 1, type.create()),
+ ),
+ ];
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
index 25c4976a1bc..c5b1504fb7c 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { toggleList, wrappingInputRule } from 'tiptap-commands';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
export default class OrderedTaskList extends Node {
@@ -25,4 +26,12 @@ export default class OrderedTaskList extends Node {
toMarkdown(state, node) {
state.renderList(node, ' ', () => '1. ');
}
+
+ commands({ type, schema }) {
+ return () => toggleList(type, schema.nodes.task_list_item);
+ }
+
+ inputRules({ type }) {
+ return [wrappingInputRule(/^\s*(\d+)\.\s(\[ \])\s$/, type)];
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
index dec3207b1bb..1a79e0e9cc4 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { setBlockType } from 'tiptap-commands';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
@@ -21,4 +22,8 @@ export default class Paragraph extends Node {
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.paragraph(state, node);
}
+
+ commands({ type }) {
+ return () => setBlockType(type);
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
index ab33bc21502..f054b10e8dd 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { toggleList, wrappingInputRule } from 'tiptap-commands';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
export default class TaskList extends Node {
@@ -25,4 +26,12 @@ export default class TaskList extends Node {
toMarkdown(state, node) {
state.renderList(node, ' ', () => '* ');
}
+
+ commands({ type, schema }) {
+ return () => toggleList(type, schema.nodes.task_list_item);
+ }
+
+ inputRules({ type }) {
+ return [wrappingInputRule(/^\s*([-+*])\s?(\[ \])\s$/, type)];
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
index d0ee7333d5e..fb67db52039 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
@@ -1,6 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Node } from 'tiptap';
+import { splitListItem, liftListItem, sinkListItem } from 'tiptap-commands';
// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
export default class TaskListItem extends Node {
@@ -16,7 +17,7 @@ export default class TaskListItem extends Node {
},
},
defining: true,
- draggable: false,
+ draggable: true,
content: 'paragraph block*',
parseDOM: [
{
@@ -46,4 +47,31 @@ export default class TaskListItem extends Node {
state.write(`[${node.attrs.done ? 'x' : ' '}] `);
state.renderContent(node);
}
+
+ get view() {
+ return {
+ props: ['node', 'updateAttrs', 'editable'],
+ methods: {
+ onChange() {
+ this.updateAttrs({
+ done: !this.node.attrs.done,
+ });
+ },
+ },
+ template: `
+ <li class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" :checked="node.attrs.done" @click="onChange">
+ <div class="todo-content" ref="content" :contenteditable="editable"></div>
+ </li>
+ `,
+ };
+ }
+
+ keys({ type }) {
+ return {
+ Enter: splitListItem(type),
+ Tab: sinkListItem(type),
+ 'Shift-Tab': liftListItem(type),
+ };
+ }
}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index b2545a5d733..cdaf77d2cbc 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -3,10 +3,13 @@ import $ from 'jquery';
import _ from 'underscore';
import Autosave from '~/autosave';
import Autosize from 'autosize';
+import { Editor, EditorContent } from 'tiptap';
+import { Placeholder, History } from 'tiptap-extensions';
+import { DOMParser } from 'prosemirror-model';
+import { TextSelection } from 'prosemirror-state';
import { __ } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
-import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import markdownHeader from './header.vue';
import markdownToolbar from './toolbar.vue';
import icon from '../icon.vue';
@@ -14,9 +17,13 @@ import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import { updateText } from '~/lib/utils/text_markdown';
import GfmAutoComplete, * as GFMConfig from '~/gfm_auto_complete';
import dropzoneInput from '~/dropzone_input';
+import editorExtensions from '~/behaviors/markdown/editor_extensions';
+import markdownSerializer from '~/behaviors/markdown/serializer';
+import { UP_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
export default {
components: {
+ EditorContent,
markdownHeader,
markdownToolbar,
icon,
@@ -120,7 +127,10 @@ export default {
autosave: null,
autocomplete: null,
dropzone: null,
+ editor: null,
currentValue: this.value,
+ editorContent: null,
+ editorContentOutdated: true,
renderedLoading: false,
renderedValue: null,
rendered: '',
@@ -134,7 +144,10 @@ export default {
return this.currentValue !== this.renderedValue;
},
needsMarkdownRender() {
- return this.renderedOutdated && this.mode === 'preview';
+ return (
+ this.renderedOutdated &&
+ ((this.mode === 'rich' && this.editorContentOutdated) || this.mode === 'preview')
+ );
},
needsPreviewGFMRender() {
return !this.renderedOutdated && this.mode === 'preview';
@@ -191,17 +204,38 @@ export default {
this.$nextTick(this.renderMarkdown);
}
},
+ renderedOutdated() {
+ if (!this.renderedOutdated) {
+ this.editorContent = this.rendered;
+ }
+ },
needsPreviewGFMRender() {
if (this.needsPreviewGFMRender) {
this.$nextTick(this.renderPreviewGFM);
}
},
+ editorContentOutdated() {
+ if (this.editorContentOutdated) {
+ if (this.renderedOutdated) {
+ this.editor.clearContent();
+ this.editorContent = null;
+ } else {
+ this.editorContent = this.rendered;
+ }
+ }
+ },
+ editorContent() {
+ if (this.editorContentOutdated && this.editorContent !== null) {
+ this.editor.setContent(this.editorContent);
+ this.editorContentOutdated = false;
+ }
+ },
value() {
this.setCurrentValue(this.value, { emitEvent: false });
},
currentValue() {
if (this.autosave) {
- this.$nextTick(() => this.autosave.save());
+ this.$nextTick(this.autosave.save);
}
if (this.mode === 'markdown') {
@@ -210,6 +244,8 @@ export default {
},
},
mounted() {
+ this.editor = this.createEditor();
+
if (this.autosaveKey.length) {
this.autosave = new Autosave($(this.$refs.textarea), this.autosaveKey);
}
@@ -224,6 +260,8 @@ export default {
this.autosizeTextarea();
},
beforeDestroy() {
+ this.editor.destroy();
+
if (this.autosave) {
this.autosave.reset();
this.autosave.dispose();
@@ -256,9 +294,33 @@ export default {
return autocomplete;
},
- setCurrentValue(newValue, { emitEvent = true } = {}) {
+ createEditor() {
+ return new Editor({
+ useBuiltInExtensions: false,
+ extensions: [
+ ...editorExtensions,
+
+ new History(),
+ new Placeholder({
+ emptyClass: 'is-empty',
+ emptyNodeText: 'Write a comment here…',
+ }),
+ ],
+ editable: this.editable,
+ onInit: ({ view }) =>
+ $(view.dom)
+ .addClass('md md-preview')
+ .on('keydown', this.onEditorKeydown),
+ onFocus: () => this.isFocused = true,
+ onBlur: () => this.isFocused = false,
+ onUpdate: this.onEditorUpdate,
+ });
+ },
+
+ setCurrentValue(newValue, { editorContentOutdated = true, emitEvent = true } = {}) {
if (newValue === this.currentValue) return;
+ this.editorContentOutdated = editorContentOutdated;
this.currentValue = newValue;
if (emitEvent) {
@@ -270,15 +332,45 @@ export default {
const blockquoteEl = document.createElement('blockquote');
blockquoteEl.appendChild(node.cloneNode(true));
- const markdown = CopyAsGFM.nodeToGFM(blockquoteEl);
+ const wrapEl = document.createElement('div');
+ wrapEl.appendChild(blockquoteEl);
+ const quoteDoc = DOMParser.fromSchema(this.editor.schema).parse(wrapEl);
- const current = this.currentValue.trim();
- const separator = current.length ? '\n\n' : '';
- this.setCurrentValue(`${current}${separator}${markdown}\n\n`);
+ this.appendDocToValue(quoteDoc);
this.$nextTick(this.focus);
},
+ appendDocToValue(appendableDoc) {
+ if (this.mode === 'rich') {
+ const { view, schema } = this.editor;
+ const { state } = view;
+ const { doc, tr } = state;
+ const endPos = doc.content.size;
+
+ // Add empty paragraph to end
+ tr.insert(endPos, schema.nodes.paragraph.create());
+
+ let replaceStart = endPos;
+ const { lastChild } = doc;
+ // If the last child is an empty paragraph, we want to replace it
+ if (lastChild.type.name === 'paragraph' && lastChild.content.size === 0) {
+ replaceStart -= lastChild.nodeSize;
+ }
+
+ // Add quote node just before empty paragraph
+ tr.replaceWith(replaceStart, endPos, appendableDoc);
+
+ view.dispatch(tr);
+ } else {
+ const markdown = markdownSerializer.serialize(appendableDoc);
+
+ const current = this.currentValue.trim();
+ const separator = current.length ? '\n\n' : '';
+ this.setCurrentValue(`${current}${separator}${markdown}\n\n`);
+ }
+ },
+
blur() {
if (this.mode === 'markdown') {
this.$refs.textarea.blur();
@@ -286,8 +378,24 @@ export default {
},
focus() {
- if (this.mode === 'markdown') {
- this.$refs.textarea.focus();
+ switch (this.mode) {
+ case 'markdown':
+ this.$refs.textarea.focus();
+ break;
+ case 'rich': {
+ const { view } = this.editor;
+ const { state } = view;
+ const { doc } = state;
+
+ // Move cursor to end
+ const endPos = doc.resolve(doc.content.size);
+ view.dispatch(state.tr.setSelection(TextSelection.between(endPos, endPos)));
+
+ this.editor.focus();
+ break;
+ }
+ default:
+ break;
}
},
@@ -319,6 +427,10 @@ export default {
this.renderedLoading = false;
this.renderedValue = text;
+
+ if (this.mode === 'rich') {
+ this.$nextTick(this.focus);
+ }
},
renderPreviewGFM() {
@@ -330,15 +442,53 @@ export default {
},
toolbarButtonClicked(button) {
- updateText({
- textArea: this.$refs.textarea,
- tag: button.tag,
- blockTag: button.tagBlock,
- wrap: !button.prepend,
- select: button.tagSelect,
- cursorOffset: button.cursorOffset,
- tagContent: button.tagContent,
- });
+ if (this.mode === 'markdown') {
+ updateText({
+ textArea: this.$refs.textarea,
+ tag: button.tag,
+ blockTag: button.tagBlock,
+ wrap: !button.prepend,
+ select: button.tagSelect,
+ cursorOffset: button.cursorOffset,
+ tagContent: button.tagContent,
+ });
+ } else {
+ const { commands, view, schema, isActive } = this.editor;
+
+ switch (button.name) {
+ case 'code':
+ if (isActive.code()) {
+ commands.code();
+ } else if (isActive.code_block()) {
+ commands.code_block();
+ } else {
+ const selectionFragment = view.state.selection.content().content;
+ const selectionDoc = schema.nodes.doc.create({}, selectionFragment);
+ const selectionMarkdown = markdownSerializer.serialize(selectionDoc);
+ if (selectionMarkdown.indexOf('\n') === -1) {
+ commands.code();
+ } else {
+ commands.code_block();
+ }
+ }
+ break;
+ case 'suggestion':
+ if (isActive.code_block()) {
+ commands.code_block();
+ } else {
+ commands.code_block({ lang: 'suggestion' });
+ if (view.state.selection.empty) {
+ view.dispatch(view.state.tr.insertText(button.tagContent));
+ }
+ }
+ break;
+ default: {
+ const command = commands[button.name];
+ if (command) command();
+ break;
+ }
+ }
+ }
},
triggerEditPrevious() {
@@ -356,6 +506,31 @@ export default {
onTextareaInput() {
this.setCurrentValue(this.$refs.textarea.value);
},
+
+ onEditorUpdate({ state: { doc } }) {
+ this.editorContent = doc.toJSON();
+ this.setCurrentValue(markdownSerializer.serialize(doc), { editorContentOutdated: false });
+ },
+
+ onEditorKeydown(e) {
+ switch (e.keyCode) {
+ case UP_KEY_CODE:
+ this.triggerEditPrevious();
+ break;
+ case ENTER_KEY_CODE:
+ if (e.metaKey || e.ctrlKey) {
+ e.preventDefault();
+ this.triggerSave();
+ }
+ break;
+ case ESC_KEY_CODE:
+ e.preventDefault();
+ this.triggerCancel();
+ break;
+ default:
+ break;
+ }
+ },
},
};
</script>
@@ -375,6 +550,7 @@ export default {
:mode="mode"
@preview="mode = 'preview'"
@markdown="mode = 'markdown'"
+ @rich="mode = 'rich'"
@toolbar-button-clicked="toolbarButtonClicked"
/>
<div v-show="mode === 'markdown'" class="md-write-holder">
@@ -410,6 +586,20 @@ export default {
</div>
</div>
+ <div v-show="mode === 'rich'" class="md-rich-holder">
+ <div :class="{ 'md-rich-editor': true, 'zen-backdrop': mode === 'rich' }">
+ <editor-content v-show="!editorContentOutdated" :editor="editor" class="editor" />
+ <span v-if="editorContentOutdated">
+ {{ __('Loading…') }}
+ </span>
+ </div>
+
+ <a class="zen-control zen-control-leave js-zen-leave" href="#" aria-label="Exit zen mode">
+ <icon :size="32" name="screen-normal" />
+ </a>
+ <markdown-toolbar :rich-text="true" :can-attach-file="false" />
+ </div>
+
<div v-show="mode === 'preview'" class="js-vue-md-preview md-preview-holder">
<span v-if="renderedOutdated">
{{ __('Loading…') }}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 0d769a7b82b..64710e54d55 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -71,6 +71,13 @@ export default {
this.$emit('markdown');
},
+ richTab(event, form) {
+ if (event.target.blur) event.target.blur();
+ if (!this.isValid(form)) return;
+
+ this.$emit('rich');
+ },
+
toolbarButtonClicked(button) {
this.$emit('toolbar-button-clicked', button);
},
@@ -83,7 +90,12 @@ export default {
<ul class="nav-links clearfix">
<li :class="{ active: mode == 'markdown' }" class="md-header-tab">
<button class="js-write-link" tabindex="-1" type="button" @click="markdownTab($event)">
- Write
+ Markdown
+ </button>
+ </li>
+ <li :class="{ active: mode == 'rich' }" class="md-header-tab">
+ <button class="js-rich-link" tabindex="-1" type="button" @click="richTab($event)">
+ Rich
</button>
</li>
<li :class="{ active: mode == 'preview' }" class="md-header-tab">
@@ -123,6 +135,7 @@ export default {
@click="toolbarButtonClicked"
/>
<toolbar-button
+ v-if="mode == 'markdown'"
name="link"
tag="[{text}](url)"
tag-select="url"
@@ -155,6 +168,7 @@ export default {
@click="toolbarButtonClicked"
/>
<toolbar-button
+ v-if="mode == 'markdown'"
name="table"
:tag="mdTable"
:prepend="true"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 3b57b5e8da4..db1f6cfa157 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -6,9 +6,15 @@ export default {
GlLink,
},
props: {
+ richText: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
markdownDocsPath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
quickActionsDocsPath: {
type: String,
@@ -32,19 +38,27 @@ export default {
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
- <template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">
- Markdown is supported
+ <template v-if="hasQuickActionsDocsPath">
+ <template v-if="richText">
+ Rich text editing
+ </template>
+ <gl-link v-else :href="markdownDocsPath" target="_blank" tabindex="-1">
+ Markdown
</gl-link>
- </template>
- <template v-if="hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"> Markdown </gl-link>
and
<gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">
quick actions
</gl-link>
are supported
</template>
+ <template v-else>
+ <template v-if="richText">
+ Rich text editing is supported
+ </template>
+ <gl-link v-else :href="markdownDocsPath" target="_blank" tabindex="-1">
+ Markdown is supported
+ </gl-link>
+ </template>
</div>
<span v-if="canAttachFile" class="uploading-container">
<span class="uploading-progress-container hide">
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index e98c4d7bf7a..641f3d69dbf 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -80,7 +80,7 @@ export default class ZenMode {
Mousetrap.pause();
this.active_backdrop = $(backdrop);
this.active_backdrop.addClass('fullscreen');
- this.active_textarea = this.active_backdrop.find('textarea');
+ this.active_textarea = this.active_backdrop.find('textarea, .ProseMirror');
// Prevent a user-resized textarea from persisting to fullscreen
this.active_textarea.removeAttr('style');
this.active_textarea.focus();
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 4bdce8269dc..02137b62aba 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -113,13 +113,25 @@
margin: 0;
}
}
+// Fix height issue!
.md-preview-holder {
- min-height: 167px;
+ min-height: 140px + 32px;
padding: 10px 0;
overflow-x: auto;
}
+.md-rich-holder {
+ .md-rich-editor {
+ padding: 10px 0;
+ min-height: 140px;
+
+ .ProseMirror {
+ min-height: 140px - 20px;
+ }
+ }
+}
+
.markdown-area {
border-radius: 0;
background: $white-light;
@@ -314,3 +326,19 @@
margin-right: 0;
}
}
+
+.md-rich-holder {
+ .editor {
+ p.is-empty:first-child::before {
+ content: attr(data-empty-text);
+ float: left;
+ color: #aaa;
+ pointer-events: none;
+ height: 0;
+ }
+ }
+
+ [contenteditable]:focus {
+ outline: none;
+ }
+}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index a08639936c0..059e64d95ee 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -47,13 +47,35 @@
font-family: $monospace-font;
white-space: pre-wrap;
word-wrap: normal;
+
+ &.math:before {
+ content: 'math: ';
+ font-size: 80%;
+ position: relative;
+ top: -1px;
+ }
}
// Multi-line code blocks should scroll horizontally
pre {
+ position: relative;
+
code {
white-space: pre;
}
+
+ &[lang]:not([lang='plaintext']):not(.js-syntax-highlight):before {
+ content: attr(lang);
+ display: block;
+ font-size: 80%;
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 0 7px;
+ border: 1px solid #e5e5e5;
+ border-width: 0 0 1px 1px;
+ border-radius: 0 0 0 2px;
+ }
}
kbd {
@@ -209,6 +231,10 @@
margin-left: 28px;
padding-left: 0;
}
+
+ > :last-child {
+ margin-bottom: 0;
+ }
}
ul.task-list {
@@ -219,11 +245,25 @@
padding-left: 28px;
margin-left: 0 !important;
- > input.task-list-item-checkbox {
+ > input.task-list-item-checkbox, > p:last-child > input.task-list-item-checkbox {
position: absolute;
left: 8px;
top: 5px;
}
+
+ > .todo-content {
+ margin-left: -4px;
+ }
+ }
+ }
+
+ .task-list > li.task-list-item {
+ > .todo-content {
+ display: inline-block;
+
+ > :last-child {
+ margin-bottom: 0;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss
index a4fbd9c073f..5ca56173e25 100644
--- a/app/assets/stylesheets/framework/zen.scss
+++ b/app/assets/stylesheets/framework/zen.scss
@@ -8,7 +8,7 @@
right: 0;
z-index: 1031;
- textarea {
+ textarea, .ProseMirror {
border: 0;
box-shadow: none;
border-radius: 0;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 51f755c67af..e7b17df1039 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -343,8 +343,7 @@ table {
.toolbar-text {
font-size: 14px;
- line-height: 16px;
- margin-top: 2px;
+ line-height: 21px;
@include media-breakpoint-up(md) {
float: left;
diff --git a/package.json b/package.json
index 97d8fd3b17f..72194ae21b0 100644
--- a/package.json
+++ b/package.json
@@ -88,6 +88,8 @@
"prismjs": "^1.6.0",
"prosemirror-markdown": "^1.3.0",
"prosemirror-model": "^1.6.4",
+ "prosemirror-inputrules": "^1.0.1",
+ "prosemirror-state": "^1.2.2",
"raphael": "^2.2.7",
"raven-js": "^3.22.1",
"raw-loader": "^1.0.0",