diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index b3a76fbb43ebf4f9cdad4556177a57101b2a7e71..3843539a3b87dcc1b6727b22e77cc8e730509e04 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -142,7 +142,8 @@ window.DropzoneInput = (function() { $(child).val(beforeSelection + formattedText + afterSelection); textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); textarea.style.height = `${textarea.scrollHeight}px`; - return form_textarea.trigger("input"); + form_textarea.trigger("input"); + form_textarea.get(0).dispatchEvent(new Event('input')); }; getFilename = function(e) { var value; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index a4d517dddedc62c5d92c32abb67939b8174b82e9..87757b1a35dacb3f7791f8baea9ad44186039b52 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -45,6 +45,14 @@ export default { type: Boolean, required: true, }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, }, data() { const store = new Store({ @@ -75,6 +83,7 @@ export default { this.store.formState = { title: this.state.titleText, confidential: this.isConfidential, + description: this.state.descriptionText, }; }, closeForm() { @@ -155,7 +164,9 @@ export default { <form-component v-if="canUpdate && showForm" :form-state="formState" - :can-destroy="canDestroy" /> + :can-destroy="canDestroy" + :markdown-docs="markdownDocs" + :markdown-preview-url="markdownPreviewUrl" /> <div v-else> <title-component :issuable-ref="issuableRef" diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 4ad3eb7dfd750f00760911e3b7f3abcf25e0f8e7..3281ec6b1726b5b4f352677d29a2ee381f4df5ec 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -18,11 +18,13 @@ }, updatedAt: { type: String, - required: true, + required: false, + default: '', }, taskStatus: { type: String, - required: true, + required: false, + default: '', }, }, data() { @@ -83,6 +85,7 @@ <template> <div + v-if="descriptionHtml" class="description" :class="{ 'js-task-list-container': canUpdate diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue new file mode 100644 index 0000000000000000000000000000000000000000..b4c31811a0b983d67549cde7498ad8896760e0b5 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -0,0 +1,47 @@ +<script> + /* global Flash */ + import markdownField from '../../../vue_shared/components/markdown/field.vue'; + + export default { + props: { + formState: { + type: Object, + required: true, + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + }, + components: { + markdownField, + }, + }; +</script> + +<template> + <div class="common-note-form"> + <label + class="sr-only" + for="issue-description"> + Description + </label> + <markdown-field + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs"> + <textarea + id="issue-description" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-supports-slash-commands="false" + aria-label="Description" + v-model="formState.description" + ref="textatea" + slot="textarea"> + </textarea> + </markdown-field> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 862558562e504b20a6bbeb907528940d8f580fae..a653609c78e438367ed29f6615d18176b235e253 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -1,5 +1,6 @@ <script> import titleField from './fields/title.vue'; + import descriptionField from './fields/description.vue'; import editActions from './edit_actions.vue'; import confidentialCheckbox from './fields/confidential_checkbox.vue'; @@ -13,9 +14,18 @@ type: Object, required: true, }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, }, components: { titleField, + descriptionField, editActions, confidentialCheckbox, }, @@ -28,6 +38,10 @@ :form-state="formState" /> <confidential-checkbox :form-state="formState" /> + <description-field + :form-state="formState" + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs" /> <edit-actions :can-destroy="canDestroy" /> </form> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index b1e8f46797994bc8a68161daf188f3d57b629a6f..3b69be05cf355978a5038eaf0ee933f6e506272c 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -26,6 +26,8 @@ document.addEventListener('DOMContentLoaded', () => { endpoint, issuableRef, isConfidential, + markdownPreviewUrl, + markdownDocs, } = issuableElement.dataset; return { @@ -37,6 +39,8 @@ document.addEventListener('DOMContentLoaded', () => { initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', isConfidential: gl.utils.convertPermissionToBoolean(isConfidential), + markdownPreviewUrl, + markdownDocs, }; }, render(createElement) { @@ -50,6 +54,8 @@ document.addEventListener('DOMContentLoaded', () => { initialDescriptionHtml: this.initialDescriptionHtml, initialDescriptionText: this.initialDescriptionText, isConfidential: this.isConfidential, + markdownPreviewUrl: this.markdownPreviewUrl, + markdownDocs: this.markdownDocs, }, }); }, diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 5af63369211e0e2f7afe422afcc37e25c7a9e4f9..d90716bef80db9152e0eed7a1027ecb9216162bd 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -15,6 +15,7 @@ export default class Store { this.formState = { title: '', confidential: false, + description: '', }; } diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue new file mode 100644 index 0000000000000000000000000000000000000000..68bbd263f02fb37e021c84a5046e3cfd159f274d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -0,0 +1,107 @@ +<script> + /* global Flash */ + import markdownHeader from './header.vue'; + import markdownToolbar from './toolbar.vue'; + + export default { + props: { + markdownPreviewUrl: { + type: String, + required: false, + default: '', + }, + markdownDocs: { + type: String, + required: true, + }, + }, + data() { + return { + markdownPreview: '', + markdownPreviewLoading: false, + previewMarkdown: false, + }; + }, + components: { + markdownHeader, + markdownToolbar, + }, + methods: { + toggleMarkdownPreview() { + this.previewMarkdown = !this.previewMarkdown; + + if (!this.previewMarkdown) { + this.markdownPreview = ''; + } else { + this.markdownPreviewLoading = true; + this.$http.post( + this.markdownPreviewUrl, + { + /* + Can't use `$refs` as the component is technically in the parent component + so we access the VNode & then get the element + */ + text: this.$slots.textarea[0].elm.value, + }, + ) + .then((res) => { + const data = res.json(); + + this.markdownPreviewLoading = false; + this.markdownPreview = data.body; + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); + }) + .catch(() => new Flash('Error loading markdown preview')); + } + }, + }, + mounted() { + /* + GLForm class handles all the toolbar buttons + */ + return new gl.GLForm($(this.$refs['gl-form'])); + }, + }; +</script> + +<template> + <div + class="md-area prepend-top-default append-bottom-default" + ref="gl-form"> + <markdown-header + :preview-markdown="previewMarkdown" + @toggle-markdown="toggleMarkdownPreview" /> + <div + class="md-write-holder" + v-show="!previewMarkdown"> + <div class="zen-backdrop"> + <slot name="textarea"></slot> + <a + class="zen-control zen-control-leave js-zen-leave" + href="#" + aria-label="Enter zen mode"> + <i + class="fa fa-compress" + aria-hidden="true"> + </i> + </a> + <markdown-toolbar + :markdown-docs="markdownDocs" /> + </div> + </div> + <div + class="md md-preview-holder md-preview" + v-show="previewMarkdown"> + <div + ref="markdown-preview" + v-html="markdownPreview"> + </div> + <span v-if="markdownPreviewLoading"> + Loading... + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue new file mode 100644 index 0000000000000000000000000000000000000000..7884b25c5efa5e68200634eb34a22372140f9166 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -0,0 +1,101 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + import toolbarButton from './toolbar_button.vue'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + previewMarkdown: { + type: Boolean, + required: true, + }, + }, + components: { + toolbarButton, + }, + methods: { + toggleMarkdownPreview(e) { + e.target.blur(); + + this.$emit('toggle-markdown'); + }, + }, + }; +</script> + +<template> + <div class="md-header"> + <ul class="nav-links clearfix"> + <li :class="{ active: !previewMarkdown }"> + <a + href="#md-write-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Write + </a> + </li> + <li :class="{ active: previewMarkdown }"> + <a + href="#md-preview-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Preview + </a> + </li> + <li class="pull-right"> + <div class="toolbar-group"> + <toolbar-button + tag="**" + button-title="Add bold text" + icon="bold" /> + <toolbar-button + tag="*" + button-title="Add italic text" + icon="italic" /> + <toolbar-button + tag="> " + :prepend="true" + button-title="Insert a quote" + icon="quote-right" /> + <toolbar-button + tag="`" + tag-block="```" + button-title="Insert code" + icon="code" /> + <toolbar-button + tag="* " + :prepend="true" + button-title="Add a bullet list" + icon="list-ul" /> + <toolbar-button + tag="1. " + :prepend="true" + button-title="Add a numbered list" + icon="list-ol" /> + <toolbar-button + tag="* [ ] " + :prepend="true" + button-title="Add a task list" + icon="check-square-o" /> + </div> + <div class="toolbar-group"> + <button + aria-label="Go full screen" + class="toolbar-btn js-zen-enter" + data-container="body" + tabindex="-1" + title="Go full screen" + type="button" + ref="tooltip"> + <i + aria-hidden="true" + class="fa fa-arrows-alt fa-fw"> + </i> + </button> + </div> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue new file mode 100644 index 0000000000000000000000000000000000000000..93252293ba68a7cd4a60f19bc7638394b689fa76 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -0,0 +1,33 @@ +<script> + export default { + props: { + markdownDocs: { + type: String, + required: true, + }, + }, + }; +</script> + +<template> + <div class="comment-toolbar clearfix"> + <div class="toolbar-text"> + <a + :href="markdownDocs" + target="_blank" + tabindex="-1"> + Markdown is supported + </a> + </div> + <button + class="toolbar-button markdown-selector" + type="button" + tabindex="-1"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"> + </i> + Attach a file + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue new file mode 100644 index 0000000000000000000000000000000000000000..096be50762572c1c265bdc8fb727126a96e06872 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -0,0 +1,58 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + buttonTitle: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + tag: { + type: String, + required: true, + }, + tagBlock: { + type: String, + required: false, + default: '', + }, + prepend: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + iconClass() { + return `fa-${this.icon}`; + }, + }, + }; +</script> + +<template> + <button + type="button" + class="toolbar-btn js-md hidden-xs" + tabindex="-1" + ref="tooltip" + data-container="body" + :data-md-tag="tag" + :data-md-block="tagBlock" + :data-md-prepend="prepend" + :title="buttonTitle" + :aria-label="buttonTitle"> + <i + aria-hidden="true" + class="fa fa-fw" + :class="iconClass"> + </i> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js index 9bb948bff66b26f88c1065b28056e2c787863306..2e3b716a36cb2ee02e06b45d5ffe90bf6afbe769 100644 --- a/app/assets/javascripts/vue_shared/mixins/tooltip.js +++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js @@ -1,9 +1,17 @@ export default { mounted() { - $(this.$refs.tooltip).tooltip(); + this.$nextTick(() => { + $(this.$refs.tooltip).tooltip(); + }); }, updated() { - $(this.$refs.tooltip).tooltip('fixTitle'); + this.$nextTick(() => { + $(this.$refs.tooltip).tooltip('fixTitle'); + }); + }, + + beforeDestroy() { + $(this.$refs.tooltip).tooltip('destroy'); }, }; diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index c21cea259a1d6633514b9f34b654424b5145d5eb..9afffdba354a37fba830f67fcde2f86648da646c 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -56,6 +56,8 @@ "can-destroy" => can?(current_user, :destroy_issue, @issue).to_s, "issuable-ref" => @issue.to_reference, "is-confidential" => @issue.confidential.to_s, + "markdown-preview-url" => preview_markdown_path(@project), + "markdown-docs" => help_page_path('user/markdown'), } } %h2.title= markdown_field(@issue, :title) - if @issue.description.present?