Commit 066a99b6 authored by Sam Bigelow's avatar Sam Bigelow

Add markdown buttons to file editor

Currently, we have markdown files in many places (e.g. comments, new
issues, etc.). This Merge Request detects if the file being edited is a
markdown file and adds markdown buttons and their functionality to the
single file editor (Not the web IDE)
parent 28cffb9f
...@@ -16,6 +16,7 @@ export default () => { ...@@ -16,6 +16,7 @@ export default () => {
const filePath = editBlobForm.data('blobFilename'); const filePath = editBlobForm.data('blobFilename');
const currentAction = $('.js-file-title').data('currentAction'); const currentAction = $('.js-file-title').data('currentAction');
const projectId = editBlobForm.data('project-id'); const projectId = editBlobForm.data('project-id');
const isMarkdown = editBlobForm.data('is-markdown');
const commitButton = $('.js-commit-button'); const commitButton = $('.js-commit-button');
const cancelLink = $('.btn.btn-cancel'); const cancelLink = $('.btn.btn-cancel');
...@@ -27,7 +28,13 @@ export default () => { ...@@ -27,7 +28,13 @@ export default () => {
window.onbeforeunload = null; window.onbeforeunload = null;
}); });
new EditBlob(`${urlRoot}${assetsPath}`, filePath, currentAction, projectId); new EditBlob({
assetsPath: `${urlRoot}${assetsPath}`,
filePath,
currentAction,
projectId,
isMarkdown,
});
new NewCommitForm(editBlobForm); new NewCommitForm(editBlobForm);
// returning here blocks page navigation // returning here blocks page navigation
......
...@@ -6,22 +6,31 @@ import createFlash from '~/flash'; ...@@ -6,22 +6,31 @@ import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import TemplateSelectorMediator from '../blob/file_template_mediator'; import TemplateSelectorMediator from '../blob/file_template_mediator';
import getModeByFileExtension from '~/lib/utils/ace_utils'; import getModeByFileExtension from '~/lib/utils/ace_utils';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
export default class EditBlob { export default class EditBlob {
constructor(assetsPath, aceMode, currentAction, projectId) { // The options object has:
this.configureAceEditor(aceMode, assetsPath); // assetsPath, filePath, currentAction, projectId, isMarkdown
constructor(options) {
this.options = options;
this.configureAceEditor();
this.initModePanesAndLinks(); this.initModePanesAndLinks();
this.initSoftWrap(); this.initSoftWrap();
this.initFileSelectors(currentAction, projectId); this.initFileSelectors();
} }
configureAceEditor(filePath, assetsPath) { configureAceEditor() {
const { filePath, assetsPath, isMarkdown } = this.options;
ace.config.set('modePath', `${assetsPath}/ace`); ace.config.set('modePath', `${assetsPath}/ace`);
ace.config.loadModule('ace/ext/searchbox'); ace.config.loadModule('ace/ext/searchbox');
ace.config.loadModule('ace/ext/modelist'); ace.config.loadModule('ace/ext/modelist');
this.editor = ace.edit('editor'); this.editor = ace.edit('editor');
if (isMarkdown) {
addEditorMarkdownListeners(this.editor);
}
// This prevents warnings re: automatic scrolling being logged // This prevents warnings re: automatic scrolling being logged
this.editor.$blockScrolling = Infinity; this.editor.$blockScrolling = Infinity;
...@@ -32,7 +41,8 @@ export default class EditBlob { ...@@ -32,7 +41,8 @@ export default class EditBlob {
} }
} }
initFileSelectors(currentAction, projectId) { initFileSelectors() {
const { currentAction, projectId } = this.options;
this.fileTemplateMediator = new TemplateSelectorMediator({ this.fileTemplateMediator = new TemplateSelectorMediator({
currentAction, currentAction,
editor: this.editor, editor: this.editor,
......
...@@ -8,6 +8,10 @@ function selectedText(text, textarea) { ...@@ -8,6 +8,10 @@ function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd); return text.substring(textarea.selectionStart, textarea.selectionEnd);
} }
function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
function lineBefore(text, textarea) { function lineBefore(text, textarea) {
var split; var split;
split = text split = text
...@@ -24,19 +28,45 @@ function lineAfter(text, textarea) { ...@@ -24,19 +28,45 @@ function lineAfter(text, textarea) {
.split('\n')[0]; .split('\n')[0];
} }
function editorBlockTagText(text, blockTag, selected, editor) {
const lines = text.split('\n');
const selectionRange = editor.getSelectionRange();
const shouldRemoveBlock =
lines[selectionRange.start.row - 1] === blockTag &&
lines[selectionRange.end.row + 1] === blockTag;
if (shouldRemoveBlock) {
if (blockTag !== null) {
// ace is globally defined
// eslint-disable-next-line no-undef
const { Range } = ace.require('ace/range');
const lastLine = lines[selectionRange.end.row + 1];
const rangeWithBlockTags = new Range(
lines[selectionRange.start.row - 1],
0,
selectionRange.end.row + 1,
lastLine.length,
);
editor.getSelection().setSelectionRange(rangeWithBlockTags);
}
return selected;
}
return addBlockTags(blockTag, selected);
}
function blockTagText(text, textArea, blockTag, selected) { function blockTagText(text, textArea, blockTag, selected) {
const before = lineBefore(text, textArea); const shouldRemoveBlock =
const after = lineAfter(text, textArea); lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag;
if (before === blockTag && after === blockTag) {
if (shouldRemoveBlock) {
// To remove the block tag we have to select the line before & after // To remove the block tag we have to select the line before & after
if (blockTag != null) { if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1); textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1); textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
} }
return selected; return selected;
} else {
return blockTag + '\n' + selected + '\n' + blockTag;
} }
return addBlockTags(blockTag, selected);
} }
function moveCursor({ function moveCursor({
...@@ -46,33 +76,48 @@ function moveCursor({ ...@@ -46,33 +76,48 @@ function moveCursor({
positionBetweenTags, positionBetweenTags,
removedLastNewLine, removedLastNewLine,
select, select,
editor,
editorSelectionStart,
editorSelectionEnd,
}) { }) {
var pos; var pos;
if (!textArea.setSelectionRange) { if (textArea && !textArea.setSelectionRange) {
return; return;
} }
if (select && select.length > 0) { if (select && select.length > 0) {
// calculate the part of the text to be selected if (textArea) {
const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select)); // calculate the part of the text to be selected
const endPosition = startPosition + select.length; const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select));
return textArea.setSelectionRange(startPosition, endPosition); const endPosition = startPosition + select.length;
} return textArea.setSelectionRange(startPosition, endPosition);
if (textArea.selectionStart === textArea.selectionEnd) { } else if (editor) {
if (positionBetweenTags) { editor.navigateLeft(tag.length - tag.indexOf(select));
pos = textArea.selectionStart - tag.length; editor.getSelection().selectAWord();
} else { return;
pos = textArea.selectionStart;
} }
}
if (textArea) {
if (textArea.selectionStart === textArea.selectionEnd) {
if (positionBetweenTags) {
pos = textArea.selectionStart - tag.length;
} else {
pos = textArea.selectionStart;
}
if (removedLastNewLine) { if (removedLastNewLine) {
pos -= 1; pos -= 1;
} }
if (cursorOffset) { if (cursorOffset) {
pos -= cursorOffset; pos -= cursorOffset;
} }
return textArea.setSelectionRange(pos, pos); return textArea.setSelectionRange(pos, pos);
}
} else if (editor && editorSelectionStart.row === editorSelectionEnd.row) {
if (positionBetweenTags) {
editor.navigateLeft(tag.length);
}
} }
} }
...@@ -85,6 +130,7 @@ export function insertMarkdownText({ ...@@ -85,6 +130,7 @@ export function insertMarkdownText({
selected = '', selected = '',
wrap, wrap,
select, select,
editor,
}) { }) {
var textToInsert, var textToInsert,
selectedSplit, selectedSplit,
...@@ -92,11 +138,20 @@ export function insertMarkdownText({ ...@@ -92,11 +138,20 @@ export function insertMarkdownText({
removedLastNewLine, removedLastNewLine,
removedFirstNewLine, removedFirstNewLine,
currentLineEmpty, currentLineEmpty,
lastNewLine; lastNewLine,
editorSelectionStart,
editorSelectionEnd;
removedLastNewLine = false; removedLastNewLine = false;
removedFirstNewLine = false; removedFirstNewLine = false;
currentLineEmpty = false; currentLineEmpty = false;
if (editor) {
const selectionRange = editor.getSelectionRange();
editorSelectionStart = selectionRange.start;
editorSelectionEnd = selectionRange.end;
}
// check for link pattern and selected text is an URL // check for link pattern and selected text is an URL
// if so fill in the url part instead of the text part of the pattern. // if so fill in the url part instead of the text part of the pattern.
if (tag === LINK_TAG_PATTERN) { if (tag === LINK_TAG_PATTERN) {
...@@ -119,14 +174,27 @@ export function insertMarkdownText({ ...@@ -119,14 +174,27 @@ export function insertMarkdownText({
} }
// Remove the last newline // Remove the last newline
if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) { if (textArea) {
removedLastNewLine = true; if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
selected = selected.replace(/\n$/, ''); removedLastNewLine = true;
selected = selected.replace(/\n$/, '');
}
} else if (editor) {
if (editorSelectionStart.row !== editorSelectionEnd.row) {
removedLastNewLine = true;
selected = selected.replace(/\n$/, '');
}
} }
selectedSplit = selected.split('\n'); selectedSplit = selected.split('\n');
if (!wrap) { if (editor && !wrap) {
lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row];
if (/^\s*$/.test(lastNewLine)) {
currentLineEmpty = true;
}
} else if (textArea && !wrap) {
lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n'); lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
// Check whether the current line is empty or consists only of spaces(=handle as empty) // Check whether the current line is empty or consists only of spaces(=handle as empty)
...@@ -135,13 +203,19 @@ export function insertMarkdownText({ ...@@ -135,13 +203,19 @@ export function insertMarkdownText({
} }
} }
startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : ''; const isBeginning =
(textArea && textArea.selectionStart === 0) ||
(editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0);
startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : '';
const textPlaceholder = '{text}'; const textPlaceholder = '{text}';
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) { if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') { if (blockTag != null && blockTag !== '') {
textToInsert = blockTagText(text, textArea, blockTag, selected); textToInsert = editor
? editorBlockTagText(text, blockTag, selected, editor)
: blockTagText(text, textArea, blockTag, selected);
} else { } else {
textToInsert = selectedSplit textToInsert = selectedSplit
.map(function(val) { .map(function(val) {
...@@ -170,7 +244,11 @@ export function insertMarkdownText({ ...@@ -170,7 +244,11 @@ export function insertMarkdownText({
textToInsert += '\n'; textToInsert += '\n';
} }
insertText(textArea, textToInsert); if (editor) {
editor.insert(textToInsert);
} else {
insertText(textArea, textToInsert);
}
return moveCursor({ return moveCursor({
textArea, textArea,
tag: tag.replace(textPlaceholder, selected), tag: tag.replace(textPlaceholder, selected),
...@@ -178,6 +256,9 @@ export function insertMarkdownText({ ...@@ -178,6 +256,9 @@ export function insertMarkdownText({
positionBetweenTags: wrap && selected.length === 0, positionBetweenTags: wrap && selected.length === 0,
removedLastNewLine, removedLastNewLine,
select, select,
editor,
editorSelectionStart,
editorSelectionEnd,
}); });
} }
...@@ -217,6 +298,25 @@ export function addMarkdownListeners(form) { ...@@ -217,6 +298,25 @@ export function addMarkdownListeners(form) {
}); });
} }
export function addEditorMarkdownListeners(editor) {
$('.js-md')
.off('click')
.on('click', function(e) {
const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data();
insertMarkdownText({
tag: mdTag,
blockTag: mdBlock,
wrap: !mdPrepend,
select: mdSelect,
selected: editor.getSelectedText(),
text: editor.getValue(),
editor,
});
editor.focus();
});
}
export function removeMarkdownListeners(form) { export function removeMarkdownListeners(form) {
return $('.js-md', form).off('click'); return $('.js-md', form).off('click');
} }
...@@ -173,7 +173,7 @@ ...@@ -173,7 +173,7 @@
svg { svg {
width: 14px; width: 14px;
height: 14px; height: 14px;
margin-top: 3px; vertical-align: middle;
fill: $gl-text-color-secondary; fill: $gl-text-color-secondary;
} }
......
...@@ -128,6 +128,10 @@ ...@@ -128,6 +128,10 @@
width: 100%; width: 100%;
} }
} }
@media(max-width: map-get($grid-breakpoints, md)-1) {
clear: both;
}
} }
.editor-ref { .editor-ref {
......
...@@ -177,7 +177,8 @@ module BlobHelper ...@@ -177,7 +177,8 @@ module BlobHelper
'relative-url-root' => Rails.application.config.relative_url_root, 'relative-url-root' => Rails.application.config.relative_url_root,
'assets-prefix' => Gitlab::Application.config.assets.prefix, 'assets-prefix' => Gitlab::Application.config.assets.prefix,
'blob-filename' => @blob && @blob.path, 'blob-filename' => @blob && @blob.path,
'project-id' => project.id 'project-id' => project.id,
'is-markdown' => @blob && @blob.path && Gitlab::MarkupHelper.gitlab_markdown?(@blob.path)
} }
end end
......
...@@ -18,17 +18,7 @@ ...@@ -18,17 +18,7 @@
Preview Preview
%li.md-header-toolbar.active %li.md-header-toolbar.active
= markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") }) = render 'projects/blob/markdown_buttons', show_fullscreen_button: true
= markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") })
= markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") })
= markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") })
= markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") })
= markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") })
= markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") })
= markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") })
= markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") })
%button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } }
= sprite_icon("screen-full")
.md-write-holder .md-write-holder
= yield = yield
......
- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create' - action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create'
- file_name = params[:id].split("/").last ||= ""
- is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name)
.file-holder-bottom-radius.file-holder.file.append-bottom-default .file-holder-bottom-radius.file-holder.file.append-bottom-default
.js-file-title.file-title.clearfix{ data: { current_action: action } } .js-file-title.file-title.clearfix{ data: { current_action: action } }
...@@ -17,6 +19,8 @@ ...@@ -17,6 +19,8 @@
required: true, class: 'form-control new-file-name js-file-path-name-input' required: true, class: 'form-control new-file-name js-file-path-name-input'
.file-buttons .file-buttons
- if is_markdown
= render 'projects/blob/markdown_buttons', show_fullscreen_button: false
= button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do = button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do
%span.no-wrap %span.no-wrap
= custom_icon('icon_no_wrap') = custom_icon('icon_no_wrap')
......
.md-header-toolbar.active
= markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") })
= markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") })
= markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") })
= markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") })
= markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") })
= markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") })
= markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") })
= markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") })
= markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") })
- if show_fullscreen_button
%button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } }
= sprite_icon("screen-full")
---
title: Add markdown helper buttons to file editor
merge_request: 23480
author:
type: added
import blobBundle from '~/blob_edit/blob_bundle'; import blobBundle from '~/blob_edit/blob_bundle';
import $ from 'jquery'; import $ from 'jquery';
window.ace = { describe('BlobBundle', () => {
config: {
set: () => {},
loadModule: () => {},
},
edit: () => ({ focus: () => {} }),
};
describe('EditBlob', () => {
beforeEach(() => { beforeEach(() => {
spyOnDependency(blobBundle, 'EditBlob').and.stub();
setFixtures(` setFixtures(`
<div class="js-edit-blob-form"> <div class="js-edit-blob-form" data-blob-filename="blah">
<button class="js-commit-button"></button> <button class="js-commit-button"></button>
<a class="btn btn-cancel" href="#"></a> <a class="btn btn-cancel" href="#"></a>
</div>`); </div>`);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment