gitea源码

EditorMarkdown.ts 7.5KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. export const EventEditorContentChanged = 'ce-editor-content-changed';
  2. export function triggerEditorContentChanged(target: HTMLElement) {
  3. target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
  4. }
  5. export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) {
  6. const startPos = textarea.selectionStart;
  7. const endPos = textarea.selectionEnd;
  8. textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
  9. textarea.selectionStart = startPos;
  10. textarea.selectionEnd = startPos + value.length;
  11. textarea.focus();
  12. triggerEditorContentChanged(textarea);
  13. }
  14. type TextareaValueSelection = {
  15. value: string;
  16. selStart: number;
  17. selEnd: number;
  18. };
  19. function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
  20. const selStart = textarea.selectionStart;
  21. const selEnd = textarea.selectionEnd;
  22. if (selEnd === selStart) return; // do not process when no selection
  23. e.preventDefault();
  24. const lines = textarea.value.split('\n');
  25. const selectedLines = [];
  26. let pos = 0;
  27. for (let i = 0; i < lines.length; i++) {
  28. if (pos > selEnd) break;
  29. if (pos >= selStart) selectedLines.push(i);
  30. pos += lines[i].length + 1;
  31. }
  32. for (const i of selectedLines) {
  33. if (e.shiftKey) {
  34. lines[i] = lines[i].replace(/^(\t| {1,2})/, '');
  35. } else {
  36. lines[i] = ` ${lines[i]}`;
  37. }
  38. }
  39. // re-calculating the selection range
  40. let newSelStart, newSelEnd;
  41. pos = 0;
  42. for (let i = 0; i < lines.length; i++) {
  43. if (i === selectedLines[0]) {
  44. newSelStart = pos;
  45. }
  46. if (i === selectedLines[selectedLines.length - 1]) {
  47. newSelEnd = pos + lines[i].length;
  48. break;
  49. }
  50. pos += lines[i].length + 1;
  51. }
  52. textarea.value = lines.join('\n');
  53. textarea.setSelectionRange(newSelStart, newSelEnd);
  54. triggerEditorContentChanged(textarea);
  55. }
  56. type MarkdownHandleIndentionResult = {
  57. handled: boolean;
  58. valueSelection?: TextareaValueSelection;
  59. };
  60. type TextLinesBuffer = {
  61. lines: string[];
  62. lengthBeforePosLine: number;
  63. posLineIndex: number;
  64. inlinePos: number
  65. };
  66. export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
  67. const lines = value.split('\n');
  68. let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
  69. for (; posLineIndex < lines.length; posLineIndex++) {
  70. const lineLength = lines[posLineIndex].length + 1;
  71. if (lengthBeforePosLine + lineLength > pos) {
  72. inlinePos = pos - lengthBeforePosLine;
  73. break;
  74. }
  75. lengthBeforePosLine += lineLength;
  76. }
  77. return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
  78. }
  79. function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
  80. const reDeeperIndention = new RegExp(`^${indention}\\s+`);
  81. const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
  82. let firstLineIdx: number;
  83. for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
  84. const line = linesBuf.lines[firstLineIdx];
  85. if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
  86. }
  87. firstLineIdx++;
  88. let num = 1;
  89. for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
  90. const oldLine = linesBuf.lines[i];
  91. const sameLevel = reSameLevel.test(oldLine);
  92. if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
  93. if (sameLevel) {
  94. const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
  95. linesBuf.lines[i] = newLine;
  96. num++;
  97. if (linesBuf.posLineIndex === i) {
  98. // need to correct the cursor inline position if the line length changes
  99. linesBuf.inlinePos += newLine.length - oldLine.length;
  100. linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
  101. linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
  102. }
  103. }
  104. }
  105. recalculateLengthBeforeLine(linesBuf);
  106. }
  107. function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
  108. linesBuf.lengthBeforePosLine = 0;
  109. for (let i = 0; i < linesBuf.posLineIndex; i++) {
  110. linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
  111. }
  112. }
  113. export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
  114. const unhandled: MarkdownHandleIndentionResult = {handled: false};
  115. if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
  116. const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
  117. const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
  118. if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
  119. // parse the indention
  120. let lineContent = line;
  121. const indention = /^\s*/.exec(lineContent)[0];
  122. lineContent = lineContent.slice(indention.length);
  123. if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
  124. // parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
  125. // there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
  126. const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
  127. let prefix = '';
  128. if (prefixMatch) {
  129. prefix = prefixMatch[0];
  130. if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
  131. }
  132. lineContent = lineContent.slice(prefix.length);
  133. if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
  134. if (!lineContent) {
  135. // clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
  136. linesBuf.lines[linesBuf.posLineIndex] = '';
  137. linesBuf.inlinePos = 0;
  138. } else {
  139. // start a new line with the same indention
  140. let newPrefix = prefix;
  141. if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
  142. newPrefix = newPrefix.replace('[x]', '[ ]');
  143. const inlinePos = linesBuf.inlinePos;
  144. linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
  145. const newLineLeft = `${indention}${newPrefix}`;
  146. const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
  147. linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
  148. linesBuf.posLineIndex++;
  149. linesBuf.inlinePos = newLineLeft.length;
  150. recalculateLengthBeforeLine(linesBuf);
  151. }
  152. markdownReformatListNumbers(linesBuf, indention);
  153. const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
  154. return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
  155. }
  156. function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
  157. const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
  158. if (!ret.handled) return;
  159. e.preventDefault();
  160. textarea.value = ret.valueSelection.value;
  161. textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
  162. triggerEditorContentChanged(textarea);
  163. }
  164. function isTextExpanderShown(textarea: HTMLElement): boolean {
  165. return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
  166. }
  167. export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
  168. textarea.addEventListener('keydown', (e) => {
  169. if (isTextExpanderShown(textarea)) return;
  170. if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
  171. // use Tab/Shift-Tab to indent/unindent the selected lines
  172. handleIndentSelection(textarea, e);
  173. } else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
  174. // use Enter to insert a new line with the same indention and prefix
  175. handleNewline(textarea, e);
  176. }
  177. });
  178. }