diff options
author | Douwe Maan <douwe@selenight.nl> | 2019-02-03 18:45:13 +0300 |
---|---|---|
committer | Douwe Maan <douwe@selenight.nl> | 2019-02-05 21:34:15 +0300 |
commit | 6b48e9cbbd8d0d60518a36eb356fffca09ad0869 (patch) | |
tree | 25c57d64390e9ef6b7b4c16d05b4c776bb0b2e86 | |
parent | 71ba26e78f41bea7b0bba088c1c6133923e61039 (diff) |
WIP: Add Rich WYSIWYG editor to Markdown fieldsdm-rich-markdown-editor
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", |