debian-mirror-gitlab/app/assets/javascripts/lib/utils/text_markdown.js

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

484 lines
13 KiB
JavaScript
Raw Normal View History

2020-05-24 23:13:21 +05:30
/* eslint-disable func-names, no-param-reassign, operator-assignment, consistent-return */
2018-05-09 12:01:36 +05:30
import $ from 'jquery';
2020-11-24 15:15:51 +05:30
import Shortcuts from '~/behaviors/shortcuts/shortcuts';
2021-03-11 19:13:27 +05:30
import { insertText } from '~/lib/utils/common_utils';
2018-03-17 18:26:18 +05:30
2018-12-13 13:39:08 +05:30
const LINK_TAG_PATTERN = '[{text}](url)';
2022-04-04 11:22:00 +05:30
// at the start of a line, find any amount of whitespace followed by
// a bullet point character (*+-) and an optional checkbox ([ ] [x])
// OR a number with a . after it and an optional checkbox ([ ] [x])
// followed by one or more whitespace characters
2022-06-21 17:19:12 +05:30
const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/;
// detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>)
const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/;
2022-04-04 11:22:00 +05:30
2018-05-09 12:01:36 +05:30
function selectedText(text, textarea) {
2018-03-17 18:26:18 +05:30
return text.substring(textarea.selectionStart, textarea.selectionEnd);
2018-05-09 12:01:36 +05:30
}
2018-03-17 18:26:18 +05:30
2019-02-15 15:39:39 +05:30
function addBlockTags(blockTag, selected) {
return `${blockTag}\n${selected}\n${blockTag}`;
}
2022-04-04 11:22:00 +05:30
function lineBefore(text, textarea, trimNewlines = true) {
let split = text.substring(0, textarea.selectionStart);
if (trimNewlines) {
split = split.trim();
}
split = split.split('\n');
2018-03-17 18:26:18 +05:30
return split[split.length - 1];
2018-05-09 12:01:36 +05:30
}
2018-03-17 18:26:18 +05:30
2022-05-07 20:08:51 +05:30
function lineAfter(text, textarea, trimNewlines = true) {
let split = text.substring(textarea.selectionEnd);
if (trimNewlines) {
split = split.trim();
} else {
// remove possible leading newline to get at the real line
split = split.replace(/^\n/, '');
}
split = split.split('\n');
return split[0];
2018-05-09 12:01:36 +05:30
}
2018-03-17 18:26:18 +05:30
2020-07-28 23:09:34 +05:30
function convertMonacoSelectionToAceFormat(sel) {
return {
start: {
row: sel.startLineNumber,
column: sel.startColumn,
},
end: {
row: sel.endLineNumber,
column: sel.endColumn,
},
};
}
function getEditorSelectionRange(editor) {
2021-01-03 14:25:43 +05:30
return convertMonacoSelectionToAceFormat(editor.getSelection());
2020-07-28 23:09:34 +05:30
}
2019-02-15 15:39:39 +05:30
function editorBlockTagText(text, blockTag, selected, editor) {
const lines = text.split('\n');
2020-07-28 23:09:34 +05:30
const selectionRange = getEditorSelectionRange(editor);
2019-02-15 15:39:39 +05:30
const shouldRemoveBlock =
lines[selectionRange.start.row - 1] === blockTag &&
lines[selectionRange.end.row + 1] === blockTag;
if (shouldRemoveBlock) {
if (blockTag !== null) {
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);
}
2018-05-09 12:01:36 +05:30
function blockTagText(text, textArea, blockTag, selected) {
2019-02-15 15:39:39 +05:30
const shouldRemoveBlock =
lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag;
if (shouldRemoveBlock) {
2018-03-17 18:26:18 +05:30
// To remove the block tag we have to select the line before & after
if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
}
return selected;
}
2019-02-15 15:39:39 +05:30
return addBlockTags(blockTag, selected);
2018-05-09 12:01:36 +05:30
}
2019-02-15 15:39:39 +05:30
function moveCursor({
textArea,
tag,
cursorOffset,
positionBetweenTags,
removedLastNewLine,
select,
editor,
editorSelectionStart,
editorSelectionEnd,
}) {
2020-01-01 13:55:28 +05:30
let pos;
2019-02-15 15:39:39 +05:30
if (textArea && !textArea.setSelectionRange) {
2018-05-09 12:01:36 +05:30
return;
}
2018-12-05 23:21:45 +05:30
if (select && select.length > 0) {
2019-02-15 15:39:39 +05:30
if (textArea) {
// calculate the part of the text to be selected
const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select));
const endPosition = startPosition + select.length;
return textArea.setSelectionRange(startPosition, endPosition);
} else if (editor) {
2021-01-03 14:25:43 +05:30
editor.selectWithinSelection(select, tag);
2019-02-15 15:39:39 +05:30
return;
2018-05-09 12:01:36 +05:30
}
2019-02-15 15:39:39 +05:30
}
if (textArea) {
if (textArea.selectionStart === textArea.selectionEnd) {
if (positionBetweenTags) {
pos = textArea.selectionStart - tag.length;
} else {
pos = textArea.selectionStart;
}
2018-05-09 12:01:36 +05:30
2019-02-15 15:39:39 +05:30
if (removedLastNewLine) {
pos -= 1;
}
2018-05-09 12:01:36 +05:30
2019-02-15 15:39:39 +05:30
if (cursorOffset) {
pos -= cursorOffset;
}
return textArea.setSelectionRange(pos, pos);
}
} else if (editor && editorSelectionStart.row === editorSelectionEnd.row) {
if (positionBetweenTags) {
2021-01-03 14:25:43 +05:30
editor.moveCursor(tag.length * -1);
2019-02-15 15:39:39 +05:30
}
2018-05-09 12:01:36 +05:30
}
}
2018-03-17 18:26:18 +05:30
2019-02-15 15:39:39 +05:30
export function insertMarkdownText({
textArea,
text,
tag,
cursorOffset,
blockTag,
selected = '',
wrap,
select,
editor,
}) {
2020-01-01 13:55:28 +05:30
let removedLastNewLine = false;
let removedFirstNewLine = false;
let currentLineEmpty = false;
let editorSelectionStart;
let editorSelectionEnd;
let lastNewLine;
let textToInsert;
2021-01-03 14:25:43 +05:30
selected = selected.toString();
2018-03-17 18:26:18 +05:30
2019-02-15 15:39:39 +05:30
if (editor) {
2020-07-28 23:09:34 +05:30
const selectionRange = getEditorSelectionRange(editor);
2019-02-15 15:39:39 +05:30
editorSelectionStart = selectionRange.start;
editorSelectionEnd = selectionRange.end;
}
2018-12-13 13:39:08 +05:30
// 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 (tag === LINK_TAG_PATTERN) {
if (URL) {
try {
2019-12-04 20:38:33 +05:30
new URL(selected); // eslint-disable-line no-new
2018-12-13 13:39:08 +05:30
// valid url
tag = '[text]({text})';
select = 'text';
} catch (e) {
// ignore - no valid url
}
}
}
2018-03-17 18:26:18 +05:30
// Remove the first newline
if (selected.indexOf('\n') === 0) {
removedFirstNewLine = true;
selected = selected.replace(/\n+/, '');
}
// Remove the last newline
2019-02-15 15:39:39 +05:30
if (textArea) {
if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
removedLastNewLine = true;
selected = selected.replace(/\n$/, '');
}
} else if (editor) {
if (editorSelectionStart.row !== editorSelectionEnd.row) {
removedLastNewLine = true;
selected = selected.replace(/\n$/, '');
}
2018-03-17 18:26:18 +05:30
}
2020-01-01 13:55:28 +05:30
const selectedSplit = selected.split('\n');
2018-03-17 18:26:18 +05:30
2019-02-15 15:39:39 +05:30
if (editor && !wrap) {
lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row];
if (/^\s*$/.test(lastNewLine)) {
currentLineEmpty = true;
}
} else if (textArea && !wrap) {
2018-03-17 18:26:18 +05:30
lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
// Check whether the current line is empty or consists only of spaces(=handle as empty)
if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) {
currentLineEmpty = true;
}
}
2019-02-15 15:39:39 +05:30
const isBeginning =
(textArea && textArea.selectionStart === 0) ||
(editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0);
2020-01-01 13:55:28 +05:30
const startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : '';
2018-12-05 23:21:45 +05:30
const textPlaceholder = '{text}';
2018-03-17 18:26:18 +05:30
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
2019-02-15 15:39:39 +05:30
textToInsert = editor
? editorBlockTagText(text, blockTag, selected, editor)
: blockTagText(text, textArea, blockTag, selected);
2018-03-17 18:26:18 +05:30
} else {
2018-12-13 13:39:08 +05:30
textToInsert = selectedSplit
2021-03-08 18:12:59 +05:30
.map((val) => {
2018-12-13 13:39:08 +05:30
if (tag.indexOf(textPlaceholder) > -1) {
return tag.replace(textPlaceholder, val);
}
if (val.indexOf(tag) === 0) {
2019-09-04 21:01:54 +05:30
return String(val.replace(tag, ''));
2018-12-13 13:39:08 +05:30
}
2020-05-24 23:13:21 +05:30
return String(tag) + val;
2018-12-13 13:39:08 +05:30
})
.join('\n');
2018-03-17 18:26:18 +05:30
}
2018-12-05 23:21:45 +05:30
} else if (tag.indexOf(textPlaceholder) > -1) {
2021-11-11 11:23:49 +05:30
textToInsert = tag.replace(textPlaceholder, () =>
2021-12-11 22:18:48 +05:30
selected.replace(/\\n/g, '\n').replace(/%br/g, '\\n'),
2021-11-11 11:23:49 +05:30
);
2018-03-17 18:26:18 +05:30
} else {
2020-05-24 23:13:21 +05:30
textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
2018-03-17 18:26:18 +05:30
}
if (removedFirstNewLine) {
2019-12-21 20:55:43 +05:30
textToInsert = `\n${textToInsert}`;
2018-03-17 18:26:18 +05:30
}
if (removedLastNewLine) {
2018-05-09 12:01:36 +05:30
textToInsert += '\n';
2018-03-17 18:26:18 +05:30
}
2019-02-15 15:39:39 +05:30
if (editor) {
2021-01-03 14:25:43 +05:30
editor.replaceSelectedText(textToInsert, select);
2019-02-15 15:39:39 +05:30
} else {
insertText(textArea, textToInsert);
}
2018-12-13 13:39:08 +05:30
return moveCursor({
textArea,
tag: tag.replace(textPlaceholder, selected),
2019-02-15 15:39:39 +05:30
cursorOffset,
positionBetweenTags: wrap && selected.length === 0,
2018-12-13 13:39:08 +05:30
removedLastNewLine,
select,
2019-02-15 15:39:39 +05:30
editor,
editorSelectionStart,
editorSelectionEnd,
2018-12-13 13:39:08 +05:30
});
2018-05-09 12:01:36 +05:30
}
2018-03-17 18:26:18 +05:30
2019-02-15 15:39:39 +05:30
function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
2020-01-01 13:55:28 +05:30
const $textArea = $(textArea);
2018-03-17 18:26:18 +05:30
textArea = $textArea.get(0);
2020-01-01 13:55:28 +05:30
const text = $textArea.val();
const selected = selectedText(text, textArea) || tagContent;
2018-03-17 18:26:18 +05:30
$textArea.focus();
2019-02-15 15:39:39 +05:30
return insertMarkdownText({
textArea,
text,
tag,
cursorOffset,
blockTag,
selected,
wrap,
select,
});
2018-05-09 12:01:36 +05:30
}
2020-11-24 15:15:51 +05:30
/* eslint-disable @gitlab/require-i18n-strings */
2022-04-04 11:22:00 +05:30
function handleSurroundSelectedText(e, textArea) {
2021-04-17 20:07:23 +05:30
if (!gon.markdown_surround_selection) return;
2022-04-04 11:22:00 +05:30
if (textArea.selectionStart === textArea.selectionEnd) return;
2021-04-17 20:07:23 +05:30
2020-11-24 15:15:51 +05:30
const keys = {
'*': '**{text}**', // wraps with bold character
_: '_{text}_', // wraps with italic character
'`': '`{text}`', // wraps with inline character
"'": "'{text}'", // single quotes
'"': '"{text}"', // double quotes
'[': '[{text}]', // brackets
'{': '{{text}}', // braces
'(': '({text})', // parentheses
'<': '<{text}>', // angle brackets
};
const tag = keys[e.key];
if (tag) {
e.preventDefault();
updateText({
tag,
2022-04-04 11:22:00 +05:30
textArea,
2020-11-24 15:15:51 +05:30
blockTag: '',
wrap: true,
select: '',
tagContent: '',
});
}
}
/* eslint-enable @gitlab/require-i18n-strings */
2022-05-07 20:08:51 +05:30
/**
* Returns the content for a new line following a list item.
*
* @param {Object} result - regex match of the current line
* @param {Object?} nextLineResult - regex match of the next line
* @returns string with the new list item
*/
function continueOlText(result, nextLineResult) {
const { indent, leader } = result.groups;
const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {};
const [numStr, postfix = ''] = leader.split('.');
const incrementBy = nextIsOl && nextIndent === indent ? 0 : 1;
const num = parseInt(numStr, 10) + incrementBy;
return `${indent}${num}.${postfix}`;
}
2022-04-04 11:22:00 +05:30
function handleContinueList(e, textArea) {
if (!gon.features?.markdownContinueLists) return;
if (!(e.key === 'Enter')) return;
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (textArea.selectionStart !== textArea.selectionEnd) return;
const currentLine = lineBefore(textArea.value, textArea, false);
const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
if (result) {
2022-05-07 20:08:51 +05:30
const { leader, indent, content, isOl } = result.groups;
2022-04-04 11:22:00 +05:30
const prevLineEmpty = !content;
if (prevLineEmpty) {
// erase previous empty list item - select the text and allow the
// natural line feed erase the text
textArea.selectionStart = textArea.selectionStart - result[0].length;
return;
}
2022-05-07 20:08:51 +05:30
let itemToInsert;
2022-06-21 17:19:12 +05:30
// Behaviors specific to either `ol` or `ul`
2022-05-07 20:08:51 +05:30
if (isOl) {
const nextLine = lineAfter(textArea.value, textArea, false);
const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
itemToInsert = continueOlText(result, nextLineResult);
} else {
2022-06-21 17:19:12 +05:30
if (currentLine.match(HR_PATTERN)) return;
2022-05-07 20:08:51 +05:30
itemToInsert = `${indent}${leader}`;
}
2022-04-04 11:22:00 +05:30
2022-06-21 17:19:12 +05:30
itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]');
2022-04-04 11:22:00 +05:30
e.preventDefault();
updateText({
2022-05-07 20:08:51 +05:30
tag: itemToInsert,
2022-04-04 11:22:00 +05:30
textArea,
blockTag: '',
wrap: false,
select: '',
tagContent: '',
});
}
}
export function keypressNoteText(e) {
const textArea = this;
2022-05-07 20:08:51 +05:30
if ($(textArea).atwho?.('isSelecting')) return;
2022-04-04 11:22:00 +05:30
handleContinueList(e, textArea);
handleSurroundSelectedText(e, textArea);
}
2020-11-24 15:15:51 +05:30
export function updateTextForToolbarBtn($toolbarBtn) {
return updateText({
textArea: $toolbarBtn.closest('.md-area').find('textarea'),
tag: $toolbarBtn.data('mdTag'),
cursorOffset: $toolbarBtn.data('mdCursorOffset'),
blockTag: $toolbarBtn.data('mdBlock'),
wrap: !$toolbarBtn.data('mdPrepend'),
select: $toolbarBtn.data('mdSelect'),
2021-04-29 21:17:54 +05:30
tagContent: $toolbarBtn.attr('data-md-tag-content'),
2020-11-24 15:15:51 +05:30
});
}
2018-05-09 12:01:36 +05:30
export function addMarkdownListeners(form) {
2020-11-24 15:15:51 +05:30
$('.markdown-area', form)
.on('keydown', keypressNoteText)
.each(function attachTextareaShortcutHandlers() {
Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn);
});
2021-02-22 17:27:13 +05:30
// eslint-disable-next-line @gitlab/no-global-event-off
2020-11-24 15:15:51 +05:30
const $allToolbarBtns = $('.js-md', form)
2018-12-13 13:39:08 +05:30
.off('click')
2021-03-08 18:12:59 +05:30
.on('click', function () {
2020-11-24 15:15:51 +05:30
const $toolbarBtn = $(this);
return updateTextForToolbarBtn($toolbarBtn);
2019-02-15 15:39:39 +05:30
});
2020-11-24 15:15:51 +05:30
return $allToolbarBtns;
2019-02-15 15:39:39 +05:30
}
export function addEditorMarkdownListeners(editor) {
2021-02-22 17:27:13 +05:30
// eslint-disable-next-line @gitlab/no-global-event-off
2019-02-15 15:39:39 +05:30
$('.js-md')
.off('click')
2021-03-08 18:12:59 +05:30
.on('click', (e) => {
2019-02-15 15:39:39 +05:30
const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data();
insertMarkdownText({
tag: mdTag,
blockTag: mdBlock,
wrap: !mdPrepend,
select: mdSelect,
selected: editor.getSelectedText(),
text: editor.getValue(),
editor,
2018-12-13 13:39:08 +05:30
});
2019-02-15 15:39:39 +05:30
editor.focus();
2018-12-13 13:39:08 +05:30
});
2018-05-09 12:01:36 +05:30
}
2018-03-17 18:26:18 +05:30
2018-05-09 12:01:36 +05:30
export function removeMarkdownListeners(form) {
2020-11-24 15:15:51 +05:30
$('.markdown-area', form)
.off('keydown', keypressNoteText)
.each(function removeTextareaShortcutHandlers() {
Shortcuts.removeMarkdownEditorShortcuts($(this));
});
2021-02-22 17:27:13 +05:30
// eslint-disable-next-line @gitlab/no-global-event-off
2018-03-17 18:26:18 +05:30
return $('.js-md', form).off('click');
2018-05-09 12:01:36 +05:30
}