insert-line-prefix.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import type { ChangeSpec, Line, Text } from '@codemirror/state';
  2. import type { EditorView } from '@codemirror/view';
  3. // https://regex101.com/r/5ILXUX/1
  4. const LEADING_SPACES = /^\s*/;
  5. // https://regex101.com/r/ScAXzy/1
  6. const createPrefixPattern = (prefix: string) =>
  7. new RegExp(`^\\s*(${prefix}+)\\s*`);
  8. const removePrefix = (text: string, prefix: string): string => {
  9. if (text.startsWith(prefix)) {
  10. return text.slice(prefix.length).trimStart();
  11. }
  12. return text;
  13. };
  14. const allLinesEmpty = (doc: Text, startLine: Line, endLine: Line): boolean => {
  15. for (let i = startLine.number; i <= endLine.number; i++) {
  16. const line = doc.line(i);
  17. if (line.text.trim() !== '') {
  18. return false;
  19. }
  20. }
  21. return true;
  22. };
  23. const allLinesHavePrefix = (
  24. doc: Text,
  25. startLine: Line,
  26. endLine: Line,
  27. prefix: string,
  28. ): boolean => {
  29. let hasNonEmptyLine = false;
  30. for (let i = startLine.number; i <= endLine.number; i++) {
  31. const line = doc.line(i);
  32. const trimmedLine = line.text.trim();
  33. if (trimmedLine !== '') {
  34. hasNonEmptyLine = true;
  35. if (!trimmedLine.startsWith(prefix)) {
  36. return false;
  37. }
  38. }
  39. }
  40. return hasNonEmptyLine;
  41. };
  42. /**
  43. * Insert or toggle a prefix at the beginning of the current line(s).
  44. * Handles multi-line selections. Removes prefix if all lines already have it.
  45. */
  46. export const insertLinePrefix = (
  47. view: EditorView,
  48. prefix: string,
  49. noSpaceIfPrefixExists = false,
  50. ): void => {
  51. const { from, to } = view.state.selection.main;
  52. const doc = view.state.doc;
  53. const startLine = doc.lineAt(from);
  54. const endLine = doc.lineAt(to);
  55. const changes: ChangeSpec[] = [];
  56. let totalLengthChange = 0;
  57. const isPrefixRemoval = allLinesHavePrefix(doc, startLine, endLine, prefix);
  58. if (allLinesEmpty(doc, startLine, endLine)) {
  59. for (let i = startLine.number; i <= endLine.number; i++) {
  60. const line = view.state.doc.line(i);
  61. const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
  62. const insertText = `${leadingSpaces}${prefix} `;
  63. const change = {
  64. from: line.from,
  65. to: line.to,
  66. insert: insertText,
  67. };
  68. changes.push(change);
  69. totalLengthChange += insertText.length - (line.to - line.from);
  70. }
  71. view.dispatch({ changes });
  72. view.dispatch({
  73. selection: {
  74. anchor: from + totalLengthChange,
  75. head: to + totalLengthChange,
  76. },
  77. });
  78. view.focus();
  79. return;
  80. }
  81. for (let i = startLine.number; i <= endLine.number; i++) {
  82. const line = view.state.doc.line(i);
  83. const trimmedLine = line.text.trim();
  84. const leadingSpaces = line.text.match(LEADING_SPACES)?.[0] || '';
  85. const contentTrimmed = line.text.trimStart();
  86. if (trimmedLine === '') {
  87. continue;
  88. }
  89. let newLine = '';
  90. let lengthChange = 0;
  91. if (isPrefixRemoval) {
  92. const prefixPattern = createPrefixPattern(prefix);
  93. const contentStartMatch = line.text.match(prefixPattern);
  94. if (contentStartMatch) {
  95. if (noSpaceIfPrefixExists) {
  96. const existingPrefixes = contentStartMatch[1];
  97. const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
  98. const newIndent = ' '.repeat(indentLevel);
  99. newLine = `${newIndent}${existingPrefixes}${prefix} ${line.text.slice(contentStartMatch[0].length)}`;
  100. } else {
  101. const indentLevel = Math.floor(leadingSpaces.length / 2) * 2;
  102. const newIndent = ' '.repeat(indentLevel);
  103. const prefixRemovedText = removePrefix(contentTrimmed, prefix);
  104. newLine = `${newIndent}${prefixRemovedText}`;
  105. }
  106. lengthChange = newLine.length - (line.to - line.from);
  107. changes.push({
  108. from: line.from,
  109. to: line.to,
  110. insert: newLine,
  111. });
  112. }
  113. } else {
  114. if (noSpaceIfPrefixExists && contentTrimmed.startsWith(prefix)) {
  115. newLine = `${leadingSpaces}${prefix}${contentTrimmed}`;
  116. } else {
  117. newLine = `${leadingSpaces}${prefix} ${contentTrimmed}`;
  118. }
  119. lengthChange = newLine.length - (line.to - line.from);
  120. changes.push({
  121. from: line.from,
  122. to: line.to,
  123. insert: newLine,
  124. });
  125. }
  126. totalLengthChange += lengthChange;
  127. }
  128. if (changes.length > 0) {
  129. view.dispatch({ changes });
  130. view.dispatch({
  131. selection: {
  132. anchor: from,
  133. head: to + totalLengthChange,
  134. },
  135. });
  136. view.focus();
  137. }
  138. };