PageRenderTime 44ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 0ms

/app/assets/javascripts/lib/utils/text_markdown.js

https://gitlab.com/523/gitlab-ce
JavaScript | 498 lines | 438 code | 43 blank | 17 comment | 69 complexity | 5ace22bcc2e322ac0e7a2335f4b0c83c MD5 | raw file
  1. /* eslint-disable func-names, no-param-reassign, operator-assignment, consistent-return */
  2. import $ from 'jquery';
  3. import Shortcuts from '~/behaviors/shortcuts/shortcuts';
  4. import { insertText } from '~/lib/utils/common_utils';
  5. const LINK_TAG_PATTERN = '[{text}](url)';
  6. // at the start of a line, find any amount of whitespace followed by
  7. // a bullet point character (*+-) and an optional checkbox ([ ] [x])
  8. // OR a number with a . after it and an optional checkbox ([ ] [x])
  9. // followed by one or more whitespace characters
  10. const LIST_LINE_HEAD_PATTERN = /^(?<indent>\s*)(?<leader>((?<isUl>[*+-])|(?<isOl>\d+\.))( \[([xX\s])\])?\s)(?<content>.)?/;
  11. // detect a horizontal rule that might be mistaken for a list item (not full pattern for an <hr>)
  12. const HR_PATTERN = /^((\s{0,3}-+\s*-+\s*-+\s*[\s-]*)|(\s{0,3}\*+\s*\*+\s*\*+\s*[\s*]*))$/;
  13. let compositioningNoteText = false;
  14. function selectedText(text, textarea) {
  15. return text.substring(textarea.selectionStart, textarea.selectionEnd);
  16. }
  17. function addBlockTags(blockTag, selected) {
  18. return `${blockTag}\n${selected}\n${blockTag}`;
  19. }
  20. function lineBefore(text, textarea, trimNewlines = true) {
  21. let split = text.substring(0, textarea.selectionStart);
  22. if (trimNewlines) {
  23. split = split.trim();
  24. }
  25. split = split.split('\n');
  26. return split[split.length - 1];
  27. }
  28. function lineAfter(text, textarea, trimNewlines = true) {
  29. let split = text.substring(textarea.selectionEnd);
  30. if (trimNewlines) {
  31. split = split.trim();
  32. } else {
  33. // remove possible leading newline to get at the real line
  34. split = split.replace(/^\n/, '');
  35. }
  36. split = split.split('\n');
  37. return split[0];
  38. }
  39. function convertMonacoSelectionToAceFormat(sel) {
  40. return {
  41. start: {
  42. row: sel.startLineNumber,
  43. column: sel.startColumn,
  44. },
  45. end: {
  46. row: sel.endLineNumber,
  47. column: sel.endColumn,
  48. },
  49. };
  50. }
  51. function getEditorSelectionRange(editor) {
  52. return convertMonacoSelectionToAceFormat(editor.getSelection());
  53. }
  54. function editorBlockTagText(text, blockTag, selected, editor) {
  55. const lines = text.split('\n');
  56. const selectionRange = getEditorSelectionRange(editor);
  57. const shouldRemoveBlock =
  58. lines[selectionRange.start.row - 1] === blockTag &&
  59. lines[selectionRange.end.row + 1] === blockTag;
  60. if (shouldRemoveBlock) {
  61. if (blockTag !== null) {
  62. const lastLine = lines[selectionRange.end.row + 1];
  63. const rangeWithBlockTags = new Range(
  64. lines[selectionRange.start.row - 1],
  65. 0,
  66. selectionRange.end.row + 1,
  67. lastLine.length,
  68. );
  69. editor.getSelection().setSelectionRange(rangeWithBlockTags);
  70. }
  71. return selected;
  72. }
  73. return addBlockTags(blockTag, selected);
  74. }
  75. function blockTagText(text, textArea, blockTag, selected) {
  76. const shouldRemoveBlock =
  77. lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag;
  78. if (shouldRemoveBlock) {
  79. // To remove the block tag we have to select the line before & after
  80. if (blockTag != null) {
  81. textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
  82. textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
  83. }
  84. return selected;
  85. }
  86. return addBlockTags(blockTag, selected);
  87. }
  88. function moveCursor({
  89. textArea,
  90. tag,
  91. cursorOffset,
  92. positionBetweenTags,
  93. removedLastNewLine,
  94. select,
  95. editor,
  96. editorSelectionStart,
  97. editorSelectionEnd,
  98. }) {
  99. let pos;
  100. if (textArea && !textArea.setSelectionRange) {
  101. return;
  102. }
  103. if (select && select.length > 0) {
  104. if (textArea) {
  105. // calculate the part of the text to be selected
  106. const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select));
  107. const endPosition = startPosition + select.length;
  108. return textArea.setSelectionRange(startPosition, endPosition);
  109. } else if (editor) {
  110. editor.selectWithinSelection(select, tag);
  111. return;
  112. }
  113. }
  114. if (textArea) {
  115. if (textArea.selectionStart === textArea.selectionEnd) {
  116. if (positionBetweenTags) {
  117. pos = textArea.selectionStart - tag.length;
  118. } else {
  119. pos = textArea.selectionStart;
  120. }
  121. if (removedLastNewLine) {
  122. pos -= 1;
  123. }
  124. if (cursorOffset) {
  125. pos -= cursorOffset;
  126. }
  127. return textArea.setSelectionRange(pos, pos);
  128. }
  129. } else if (editor && editorSelectionStart.row === editorSelectionEnd.row) {
  130. if (positionBetweenTags) {
  131. editor.moveCursor(tag.length * -1);
  132. }
  133. }
  134. }
  135. export function insertMarkdownText({
  136. textArea,
  137. text,
  138. tag,
  139. cursorOffset,
  140. blockTag,
  141. selected = '',
  142. wrap,
  143. select,
  144. editor,
  145. }) {
  146. let removedLastNewLine = false;
  147. let removedFirstNewLine = false;
  148. let currentLineEmpty = false;
  149. let editorSelectionStart;
  150. let editorSelectionEnd;
  151. let lastNewLine;
  152. let textToInsert;
  153. selected = selected.toString();
  154. if (editor) {
  155. const selectionRange = getEditorSelectionRange(editor);
  156. editorSelectionStart = selectionRange.start;
  157. editorSelectionEnd = selectionRange.end;
  158. }
  159. // check for link pattern and selected text is an URL
  160. // if so fill in the url part instead of the text part of the pattern.
  161. if (tag === LINK_TAG_PATTERN) {
  162. if (URL) {
  163. try {
  164. new URL(selected); // eslint-disable-line no-new
  165. // valid url
  166. tag = '[text]({text})';
  167. select = 'text';
  168. } catch (e) {
  169. // ignore - no valid url
  170. }
  171. }
  172. }
  173. // Remove the first newline
  174. if (selected.indexOf('\n') === 0) {
  175. removedFirstNewLine = true;
  176. selected = selected.replace(/\n+/, '');
  177. }
  178. // Remove the last newline
  179. if (textArea) {
  180. if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
  181. removedLastNewLine = true;
  182. selected = selected.replace(/\n$/, '');
  183. }
  184. } else if (editor) {
  185. if (editorSelectionStart.row !== editorSelectionEnd.row) {
  186. removedLastNewLine = true;
  187. selected = selected.replace(/\n$/, '');
  188. }
  189. }
  190. const selectedSplit = selected.split('\n');
  191. if (editor && !wrap) {
  192. lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row];
  193. if (/^\s*$/.test(lastNewLine)) {
  194. currentLineEmpty = true;
  195. }
  196. } else if (textArea && !wrap) {
  197. lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
  198. // Check whether the current line is empty or consists only of spaces(=handle as empty)
  199. if (/^\s*$/.test(textArea.value.substring(lastNewLine, textArea.selectionStart))) {
  200. currentLineEmpty = true;
  201. }
  202. }
  203. const isBeginning =
  204. (textArea && textArea.selectionStart === 0) ||
  205. (editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0);
  206. const startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : '';
  207. const textPlaceholder = '{text}';
  208. if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
  209. if (blockTag != null && blockTag !== '') {
  210. textToInsert = editor
  211. ? editorBlockTagText(text, blockTag, selected, editor)
  212. : blockTagText(text, textArea, blockTag, selected);
  213. } else {
  214. textToInsert = selectedSplit
  215. .map((val) => {
  216. if (tag.indexOf(textPlaceholder) > -1) {
  217. return tag.replace(textPlaceholder, val);
  218. }
  219. if (val.indexOf(tag) === 0) {
  220. return String(val.replace(tag, ''));
  221. }
  222. return String(tag) + val;
  223. })
  224. .join('\n');
  225. }
  226. } else if (tag.indexOf(textPlaceholder) > -1) {
  227. textToInsert = tag.replace(textPlaceholder, () =>
  228. selected.replace(/\\n/g, '\n').replace(/%br/g, '\\n'),
  229. );
  230. } else {
  231. textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
  232. }
  233. if (removedFirstNewLine) {
  234. textToInsert = `\n${textToInsert}`;
  235. }
  236. if (removedLastNewLine) {
  237. textToInsert += '\n';
  238. }
  239. if (editor) {
  240. editor.replaceSelectedText(textToInsert, select);
  241. } else {
  242. insertText(textArea, textToInsert);
  243. }
  244. return moveCursor({
  245. textArea,
  246. tag: tag.replace(textPlaceholder, selected),
  247. cursorOffset,
  248. positionBetweenTags: wrap && selected.length === 0,
  249. removedLastNewLine,
  250. select,
  251. editor,
  252. editorSelectionStart,
  253. editorSelectionEnd,
  254. });
  255. }
  256. function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
  257. const $textArea = $(textArea);
  258. textArea = $textArea.get(0);
  259. const text = $textArea.val();
  260. const selected = selectedText(text, textArea) || tagContent;
  261. $textArea.focus();
  262. return insertMarkdownText({
  263. textArea,
  264. text,
  265. tag,
  266. cursorOffset,
  267. blockTag,
  268. selected,
  269. wrap,
  270. select,
  271. });
  272. }
  273. /* eslint-disable @gitlab/require-i18n-strings */
  274. function handleSurroundSelectedText(e, textArea) {
  275. if (!gon.markdown_surround_selection) return;
  276. if (textArea.selectionStart === textArea.selectionEnd) return;
  277. const keys = {
  278. '*': '**{text}**', // wraps with bold character
  279. _: '_{text}_', // wraps with italic character
  280. '`': '`{text}`', // wraps with inline character
  281. "'": "'{text}'", // single quotes
  282. '"': '"{text}"', // double quotes
  283. '[': '[{text}]', // brackets
  284. '{': '{{text}}', // braces
  285. '(': '({text})', // parentheses
  286. '<': '<{text}>', // angle brackets
  287. };
  288. const tag = keys[e.key];
  289. if (tag) {
  290. e.preventDefault();
  291. updateText({
  292. tag,
  293. textArea,
  294. blockTag: '',
  295. wrap: true,
  296. select: '',
  297. tagContent: '',
  298. });
  299. }
  300. }
  301. /* eslint-enable @gitlab/require-i18n-strings */
  302. /**
  303. * Returns the content for a new line following a list item.
  304. *
  305. * @param {Object} result - regex match of the current line
  306. * @param {Object?} nextLineResult - regex match of the next line
  307. * @returns string with the new list item
  308. */
  309. function continueOlText(result, nextLineResult) {
  310. const { indent, leader } = result.groups;
  311. const { indent: nextIndent, isOl: nextIsOl } = nextLineResult?.groups ?? {};
  312. const [numStr, postfix = ''] = leader.split('.');
  313. const incrementBy = nextIsOl && nextIndent === indent ? 0 : 1;
  314. const num = parseInt(numStr, 10) + incrementBy;
  315. return `${indent}${num}.${postfix}`;
  316. }
  317. function handleContinueList(e, textArea) {
  318. if (!(e.key === 'Enter')) return;
  319. if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
  320. if (textArea.selectionStart !== textArea.selectionEnd) return;
  321. // prevent unintended line breaks were inserted using Japanese IME on MacOS
  322. if (compositioningNoteText) return;
  323. const currentLine = lineBefore(textArea.value, textArea, false);
  324. const result = currentLine.match(LIST_LINE_HEAD_PATTERN);
  325. if (result) {
  326. const { leader, indent, content, isOl } = result.groups;
  327. const prevLineEmpty = !content;
  328. if (prevLineEmpty) {
  329. // erase previous empty list item - select the text and allow the
  330. // natural line feed erase the text
  331. textArea.selectionStart = textArea.selectionStart - result[0].length;
  332. return;
  333. }
  334. let itemToInsert;
  335. // Behaviors specific to either `ol` or `ul`
  336. if (isOl) {
  337. const nextLine = lineAfter(textArea.value, textArea, false);
  338. const nextLineResult = nextLine.match(LIST_LINE_HEAD_PATTERN);
  339. itemToInsert = continueOlText(result, nextLineResult);
  340. } else {
  341. if (currentLine.match(HR_PATTERN)) return;
  342. itemToInsert = `${indent}${leader}`;
  343. }
  344. itemToInsert = itemToInsert.replace(/\[x\]/i, '[ ]');
  345. e.preventDefault();
  346. updateText({
  347. tag: itemToInsert,
  348. textArea,
  349. blockTag: '',
  350. wrap: false,
  351. select: '',
  352. tagContent: '',
  353. });
  354. }
  355. }
  356. export function keypressNoteText(e) {
  357. const textArea = this;
  358. if ($(textArea).atwho?.('isSelecting')) return;
  359. handleContinueList(e, textArea);
  360. handleSurroundSelectedText(e, textArea);
  361. }
  362. export function compositionStartNoteText() {
  363. compositioningNoteText = true;
  364. }
  365. export function compositionEndNoteText() {
  366. compositioningNoteText = false;
  367. }
  368. export function updateTextForToolbarBtn($toolbarBtn) {
  369. return updateText({
  370. textArea: $toolbarBtn.closest('.md-area').find('textarea'),
  371. tag: $toolbarBtn.data('mdTag'),
  372. cursorOffset: $toolbarBtn.data('mdCursorOffset'),
  373. blockTag: $toolbarBtn.data('mdBlock'),
  374. wrap: !$toolbarBtn.data('mdPrepend'),
  375. select: $toolbarBtn.data('mdSelect'),
  376. tagContent: $toolbarBtn.attr('data-md-tag-content'),
  377. });
  378. }
  379. export function addMarkdownListeners(form) {
  380. $('.markdown-area', form)
  381. .on('keydown', keypressNoteText)
  382. .on('compositionstart', compositionStartNoteText)
  383. .on('compositionend', compositionEndNoteText)
  384. .each(function attachTextareaShortcutHandlers() {
  385. Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn);
  386. });
  387. // eslint-disable-next-line @gitlab/no-global-event-off
  388. const $allToolbarBtns = $('.js-md', form)
  389. .off('click')
  390. .on('click', function () {
  391. const $toolbarBtn = $(this);
  392. return updateTextForToolbarBtn($toolbarBtn);
  393. });
  394. return $allToolbarBtns;
  395. }
  396. export function addEditorMarkdownListeners(editor) {
  397. // eslint-disable-next-line @gitlab/no-global-event-off
  398. $('.js-md')
  399. .off('click')
  400. .on('click', (e) => {
  401. const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data();
  402. insertMarkdownText({
  403. tag: mdTag,
  404. blockTag: mdBlock,
  405. wrap: !mdPrepend,
  406. select: mdSelect,
  407. selected: editor.getSelectedText(),
  408. text: editor.getValue(),
  409. editor,
  410. });
  411. editor.focus();
  412. });
  413. }
  414. export function removeMarkdownListeners(form) {
  415. $('.markdown-area', form)
  416. .off('keydown', keypressNoteText)
  417. .off('compositionstart', compositionStartNoteText)
  418. .off('compositionend', compositionEndNoteText)
  419. .each(function removeTextareaShortcutHandlers() {
  420. Shortcuts.removeMarkdownEditorShortcuts($(this));
  421. });
  422. // eslint-disable-next-line @gitlab/no-global-event-off
  423. return $('.js-md', form).off('click');
  424. }