'use strict'
import { commands, ExtensionContext, Position, Range, Selection, TextEditor, window, workspace, WorkspaceEdit } from 'vscode';
import { isInFencedCodeBlock, mathEnvCheck } from './util';
export function activate(context: ExtensionContext) {
context.subscriptions.push(
commands.registerCommand('markdown.extension.onEnterKey', onEnterKey),
commands.registerCommand('markdown.extension.onCtrlEnterKey', () => { return onEnterKey('ctrl'); }),
commands.registerCommand('markdown.extension.onShiftEnterKey', () => { return onEnterKey('shift'); }),
commands.registerCommand('markdown.extension.onTabKey', onTabKey),
commands.registerCommand('markdown.extension.onShiftTabKey', () => { return onTabKey('shift'); }),
commands.registerCommand('markdown.extension.onBackspaceKey', onBackspaceKey),
commands.registerCommand('markdown.extension.checkTaskList', checkTaskList),
commands.registerCommand('markdown.extension.onMoveLineDown', onMoveLineDown),
commands.registerCommand('markdown.extension.onMoveLineUp', onMoveLineUp),
commands.registerCommand('markdown.extension.onCopyLineDown', onCopyLineDown),
commands.registerCommand('markdown.extension.onCopyLineUp', onCopyLineUp),
commands.registerCommand('markdown.extension.onIndentLines', onIndentLines),
commands.registerCommand('markdown.extension.onOutdentLines', onOutdentLines)
);
}
function onEnterKey(modifiers?: string) {
let editor = window.activeTextEditor;
let cursorPos: Position = editor.selection.active;
let line = editor.document.lineAt(cursorPos.line);
let textBeforeCursor = line.text.substr(0, cursorPos.character);
let textAfterCursor = line.text.substr(cursorPos.character);
let lineBreakPos = cursorPos;
if (modifiers == 'ctrl') {
lineBreakPos = line.range.end;
}
if (modifiers == 'shift' || isInFencedCodeBlock(editor.document, cursorPos.line) || mathEnvCheck(editor.document, cursorPos)) {
return asNormal('enter', modifiers);
}
//// This is a possibility that the current line is a thematic break `
` (GitHub #785)
const lineTextNoSpace = line.text.replace(/\s/g, '');
if (lineTextNoSpace.length > 2
&& (
lineTextNoSpace.replace(/\-/g, '').length === 0
|| lineTextNoSpace.replace(/\*/g, '').length === 0
)
) {
return asNormal('enter', modifiers);
}
//// If it's an empty list item, remove it
if (/^(>|([-+*]|[0-9]+[.)])( +\[[ x]\])?)$/.test(textBeforeCursor.trim()) && textAfterCursor.trim().length == 0) {
return editor.edit(editBuilder => {
editBuilder.delete(line.range);
editBuilder.insert(line.range.end, '\n');
}).then(() => {
editor.revealRange(editor.selection);
}).then(() => fixMarker(findNextMarkerLineNumber()));
}
let matches;
if (/^> /.test(textBeforeCursor)) {
// Quote block
return editor.edit(editBuilder => {
editBuilder.insert(lineBreakPos, `\n> `);
}).then(() => {
// Fix cursor position
if (modifiers == 'ctrl' && !cursorPos.isEqual(lineBreakPos)) {
let newCursorPos = cursorPos.with(line.lineNumber + 1, 2);
editor.selection = new Selection(newCursorPos, newCursorPos);
}
}).then(() => { editor.revealRange(editor.selection) });
} else if ((matches = /^(\s*[-+*] +(\[[ x]\] +)?)/.exec(textBeforeCursor)) !== null) {
// Unordered list
return editor.edit(editBuilder => {
editBuilder.insert(lineBreakPos, `\n${matches[1].replace('[x]', '[ ]')}`);
}).then(() => {
// Fix cursor position
if (modifiers == 'ctrl' && !cursorPos.isEqual(lineBreakPos)) {
let newCursorPos = cursorPos.with(line.lineNumber + 1, matches[1].length);
editor.selection = new Selection(newCursorPos, newCursorPos);
}
}).then(() => { editor.revealRange(editor.selection) });
} else if ((matches = /^(\s*)([0-9]+)([.)])( +)((\[[ x]\] +)?)/.exec(textBeforeCursor)) !== null) {
// Ordered list
let config = workspace.getConfiguration('markdown.extension.orderedList').get('marker');
let marker = '1';
let leadingSpace = matches[1];
let previousMarker = matches[2];
let delimiter = matches[3];
let trailingSpace = matches[4];
let gfmCheckbox = matches[5].replace('[x]', '[ ]');
let textIndent = (previousMarker + delimiter + trailingSpace).length;
if (config == 'ordered') {
marker = String(Number(previousMarker) + 1);
}
// Add enough trailing spaces so that the text is aligned with the previous list item, but always keep at least one space
trailingSpace = " ".repeat(Math.max(1, textIndent - (marker + delimiter).length));
const toBeAdded = leadingSpace + marker + delimiter + trailingSpace + gfmCheckbox;
return editor.edit(
editBuilder => {
editBuilder.insert(lineBreakPos, `\n${toBeAdded}`);
},
{ undoStopBefore: true, undoStopAfter: false }
).then(() => {
// Fix cursor position
if (modifiers == 'ctrl' && !cursorPos.isEqual(lineBreakPos)) {
let newCursorPos = cursorPos.with(line.lineNumber + 1, toBeAdded.length);
editor.selection = new Selection(newCursorPos, newCursorPos);
}
}).then(() => fixMarker()).then(() => { editor.revealRange(editor.selection) });
} else {
return asNormal('enter', modifiers);
}
}
function onTabKey(modifiers?: string) {
let editor = window.activeTextEditor;
let cursorPos = editor.selection.start;
let lineText = editor.document.lineAt(cursorPos.line).text;
if (isInFencedCodeBlock(editor.document, cursorPos.line) || mathEnvCheck(editor.document, cursorPos)) {
return asNormal('tab', modifiers);
}
let match = /^\s*([-+*]|[0-9]+[.)]) +(\[[ x]\] +)?/.exec(lineText);
if (
match
&& (
modifiers === 'shift'
|| !editor.selection.isEmpty
|| editor.selection.isEmpty && cursorPos.character <= match[0].length
)
) {
if (modifiers === 'shift') {
return outdent(editor).then(() => fixMarker());
} else {
return indent(editor).then(() => fixMarker());
}
} else {
return asNormal('tab', modifiers);
}
}
function onBackspaceKey() {
let editor = window.activeTextEditor
let cursor = editor.selection.active;
let document = editor.document;
let textBeforeCursor = document.lineAt(cursor.line).text.substr(0, cursor.character);
if (isInFencedCodeBlock(document, cursor.line) || mathEnvCheck(editor.document, cursor)) {
return asNormal('backspace');
}
if (!editor.selection.isEmpty) {
return asNormal('backspace').then(() => fixMarker(findNextMarkerLineNumber()));
} else if (/^\s+([-+*]|[0-9]+[.)]) $/.test(textBeforeCursor)) {
// e.g. textBeforeCursor === ` - `, ` 1. `
return outdent(editor).then(() => fixMarker());
} else if (/^([-+*]|[0-9]+[.)]) $/.test(textBeforeCursor)) {
// e.g. textBeforeCursor === `- `, `1. `
return editor.edit(editBuilder => {
editBuilder.replace(new Range(cursor.with({ character: 0 }), cursor), ' '.repeat(textBeforeCursor.length))
}).then(() => fixMarker(findNextMarkerLineNumber()));
} else if (/^\s*([-+*]|[0-9]+[.)]) +(\[[ x]\] )$/.test(textBeforeCursor)) {
// e.g. textBeforeCursor === `- [ ]`, `1. [x]`, ` - [x]`
return deleteRange(editor, new Range(cursor.with({ character: textBeforeCursor.length - 4 }), cursor)).then(() => fixMarker(findNextMarkerLineNumber()));
} else {
return asNormal('backspace');
}
}
function asNormal(key: string, modifiers?: string) {
switch (key) {
case 'enter':
if (modifiers === 'ctrl') {
return commands.executeCommand('editor.action.insertLineAfter');
} else {
return commands.executeCommand('type', { source: 'keyboard', text: '\n' });
}
case 'tab':
if (modifiers === 'shift') {
return commands.executeCommand('editor.action.outdentLines');
} else if (
window.activeTextEditor.selection.isEmpty
&& workspace.getConfiguration('emmet').get('triggerExpansionOnTab')
) {
return commands.executeCommand('editor.emmet.action.expandAbbreviation');
} else {
return commands.executeCommand('tab');
}
case 'backspace':
return commands.executeCommand('deleteLeft');
}
}
/**
* If
*
* 1. it is not the first line
* 2. there is a Markdown list item before this line
*
* then indent the current line to align with the previous list item.
*/
function indent(editor?: TextEditor) {
if (!editor) {
editor = window.activeTextEditor;
}
if (workspace.getConfiguration("markdown.extension.list", editor.document.uri).get("indentationSize") === "adaptive") {
try {
const selection = editor.selection;
const indentationSize = tryDetermineIndentationSize(editor, selection.start.line, editor.document.lineAt(selection.start.line).firstNonWhitespaceCharacterIndex);
let edit = new WorkspaceEdit()
for (let i = selection.start.line; i <= selection.end.line; i++) {
if (i === selection.end.line && !selection.isEmpty && selection.end.character === 0) {
break;
}
if (editor.document.lineAt(i).text.length !== 0) {
edit.insert(editor.document.uri, new Position(i, 0), ' '.repeat(indentationSize));
}
}
return workspace.applyEdit(edit);
} catch (error) { }
}
return commands.executeCommand('editor.action.indentLines');
}
/**
* Similar to `indent`-function
*/
function outdent(editor?: TextEditor) {
if (!editor) {
editor = window.activeTextEditor;
}
if (workspace.getConfiguration("markdown.extension.list", editor.document.uri).get("indentationSize") === "adaptive") {
try {
const selection = editor.selection;
const indentationSize = tryDetermineIndentationSize(editor, selection.start.line, editor.document.lineAt(selection.start.line).firstNonWhitespaceCharacterIndex);
let edit = new WorkspaceEdit()
for (let i = selection.start.line; i <= selection.end.line; i++) {
if (i === selection.end.line && !selection.isEmpty && selection.end.character === 0) {
break;
}
const lineText = editor.document.lineAt(i).text;
let maxOutdentSize: number;
if (lineText.trim().length === 0) {
maxOutdentSize = lineText.length;
} else {
maxOutdentSize = editor.document.lineAt(i).firstNonWhitespaceCharacterIndex;
}
if (maxOutdentSize > 0) {
edit.delete(editor.document.uri, new Range(i, 0, i, Math.min(indentationSize, maxOutdentSize)));
}
}
return workspace.applyEdit(edit);
} catch (error) { }
}
return commands.executeCommand('editor.action.outdentLines');
}
function tryDetermineIndentationSize(editor: TextEditor, line: number, currentIndentation: number) {
while (--line >= 0) {
const lineText = editor.document.lineAt(line).text;
let matches;
if ((matches = /^(\s*)(([-+*]|[0-9]+[.)]) +)(\[[ x]\] +)?/.exec(lineText)) !== null) {
if (matches[1].length <= currentIndentation) {
return matches[2].length;
}
}
}
throw "No previous Markdown list item";
}
/**
* Returns the line number of the next ordered list item starting either from
* the specified line or the beginning of the current selection.
*/
function findNextMarkerLineNumber(line?: number): number {
let editor = window.activeTextEditor;
if (line === undefined) {
// Use start.line instead of active.line so that we can find the first
// marker following either the cursor or the entire selected range
line = editor.selection.start.line;
}
while (line < editor.document.lineCount) {
const lineText = editor.document.lineAt(line).text;
if (lineText.startsWith('#')) {
// Don't go searching past any headings
return -1;
}
if (/^\s*[0-9]+[.)] +/.exec(lineText) !== null) {
return line;
}
line++;
}
return undefined;
}
/**
* Looks for the previous ordered list marker at the same indentation level
* and returns the marker number that should follow it.
*
* @returns the fixed marker number
*/
function lookUpwardForMarker(editor: TextEditor, line: number, currentIndentation: number): number {
while (--line >= 0) {
const lineText = editor.document.lineAt(line).text;
let matches;
if ((matches = /^(\s*)(([0-9]+)[.)] +)/.exec(lineText)) !== null) {
let leadingSpace: string = matches[1];
let marker = matches[3];
if (leadingSpace.length === currentIndentation) {
return Number(marker) + 1;
} else if (
(!leadingSpace.includes('\t') && leadingSpace.length + matches[2].length <= currentIndentation)
|| leadingSpace.includes('\t') && leadingSpace.length + 1 <= currentIndentation
) {
return 1;
}
} else if ((matches = /^(\s*)\S/.exec(lineText)) !== null) {
if (matches[1].length <= currentIndentation) {
break;
}
}
}
return 1;
}
/**
* Fix ordered list marker *iteratively* starting from current line
*/
export function fixMarker(line?: number) {
if (!workspace.getConfiguration('markdown.extension.orderedList').get('autoRenumber')) return;
if (workspace.getConfiguration('markdown.extension.orderedList').get('marker') == 'one') return;
let editor = window.activeTextEditor;
if (line === undefined) {
// Use either the first line containing an ordered list marker within the selection or the active line
line = findNextMarkerLineNumber();
if (line === undefined || line > editor.selection.end.line) {
line = editor.selection.active.line;
}
}
if (line < 0 || editor.document.lineCount <= line) {
return;
}
let currentLineText = editor.document.lineAt(line).text;
let matches;
if ((matches = /^(\s*)([0-9]+)([.)])( +)/.exec(currentLineText)) !== null) { // ordered list
let leadingSpace = matches[1];
let marker = matches[2];
let delimiter = matches[3];
let trailingSpace = matches[4];
let fixedMarker = lookUpwardForMarker(editor, line, leadingSpace.length);
let listIndent = marker.length + delimiter.length + trailingSpace.length;
let fixedMarkerString = String(fixedMarker);
return editor.edit(
editBuilder => {
if (marker === fixedMarkerString) {
return;
}
// Add enough trailing spaces so that the text is still aligned at the same indentation level as it was previously, but always keep at least one space
fixedMarkerString += delimiter + " ".repeat(Math.max(1, listIndent - (fixedMarkerString + delimiter).length));
editBuilder.replace(new Range(line, leadingSpace.length, line, leadingSpace.length + listIndent), fixedMarkerString);
},
{ undoStopBefore: false, undoStopAfter: false }
).then(() => {
let nextLine = line + 1;
let indentString = " ".repeat(listIndent);
while (editor.document.lineCount > nextLine) {
const nextLineText = editor.document.lineAt(nextLine).text;
if (/^\s*[0-9]+[.)] +/.test(nextLineText)) {
return fixMarker(nextLine);
} else if (/^\s*$/.test(nextLineText)) {
nextLine++;
} else if (listIndent <= 4 && !nextLineText.startsWith(indentString)) {
return;
} else {
nextLine++;
}
}
});
}
}
function deleteRange(editor: TextEditor, range: Range): Thenable {
return editor.edit(
editBuilder => {
editBuilder.delete(range);
},
// We will enable undoStop after fixing markers
{ undoStopBefore: true, undoStopAfter: false }
);
}
function checkTaskList() {
// - Look into selections for lines that could be checked/unchecked.
// - The first matching line dictates the new state for all further lines.
// - I.e. if the first line is unchecked, only other unchecked lines will
// be considered, and vice versa.
let editor = window.activeTextEditor;
const uncheckedRegex = /^(\s*([-+*]|[0-9]+[.)]) +\[) \]/
const checkedRegex = /^(\s*([-+*]|[0-9]+[.)]) +\[)x\]/
let toBeToggled: Position[] = [] // all spots that have an "[x]" resp. "[ ]" which should be toggled
let newState: boolean | undefined = undefined // true = "x", false = " ", undefined = no matching lines
// go through all touched lines of all selections.
for (const selection of editor.selections) {
for (let i = selection.start.line; i <= selection.end.line; i++) {
const line = editor.document.lineAt(i);
const lineStart = line.range.start;
if (!selection.isSingleLine && (selection.start.isEqual(line.range.end) || selection.end.isEqual(line.range.start))) {
continue;
}
let matches: RegExpExecArray;
if (
(matches = uncheckedRegex.exec(line.text))
&& newState !== false
) {
toBeToggled.push(lineStart.with({ character: matches[1].length }));
newState = true;
} else if (
(matches = checkedRegex.exec(line.text))
&& newState !== true
) {
toBeToggled.push(lineStart.with({ character: matches[1].length }));
newState = false;
}
}
}
if (newState !== undefined) {
const newChar = newState ? 'x' : ' ';
return editor.edit(editBuilder => {
for (const pos of toBeToggled) {
let range = new Range(pos, pos.with({ character: pos.character + 1 }));
editBuilder.replace(range, newChar);
}
});
}
}
function onMoveLineUp() {
return commands.executeCommand('editor.action.moveLinesUpAction')
.then(() => fixMarker());
}
function onMoveLineDown() {
return commands.executeCommand('editor.action.moveLinesDownAction')
.then(() => fixMarker(findNextMarkerLineNumber(window.activeTextEditor.selection.start.line - 1)));
}
function onCopyLineUp() {
return commands.executeCommand('editor.action.copyLinesUpAction')
.then(() => fixMarker());
}
function onCopyLineDown() {
return commands.executeCommand('editor.action.copyLinesDownAction')
.then(() => fixMarker());
}
function onIndentLines() {
return indent().then(() => fixMarker());
}
function onOutdentLines() {
return outdent().then(() => fixMarker());
}
export function deactivate() { }