Notebook UI - Markdown toolbar - Headings dropdown (#11049)

* Adds heading dropdown to markdown toolbar.

* Added a method specific to headings that places markdown at beginning of line selected.

* Rewrote comment for my new method.

* Revised code to support multi select for headers, similar to how unordered list is applied. Multi-line headings can be undone if the multi lines are selected.

* Modified transformText to make single-line undo operation possible with just the cursor position.

* Added utility methods to help determine if the selection is a line-only or multi-line.

* Building isReplaceOperation to determine when preceeding characters need to be replaced with a new MarkdownButtonType.

* Updated comments.

* Applied changes written by Chris.

* Reverted changes to earlier stage where heading addition works just like list item additions.

* getExtendedSelectedText now returns an actual value in range for MarkdownLineType.EVERY_LINE.

* Added conditional so that Preview element is updated only when Preview is enabled.

* Updated tests for heading toolbar: heading 1, 2 and 3.

* Removed code that could not be reached.

* Corrected tests for headings.

* wip

(cherry picked from commit 43deb9635cc0eeebaffef22d4373f1f6ad713ace)

* cleanup

* fix error

* Fix tests

* Add more testing

* delete

* re-add

Co-authored-by: chgagnon <chgagnon@microsoft.com>
This commit is contained in:
Hale Rankin
2020-07-02 17:30:29 -07:00
committed by GitHub
parent a06a06bb58
commit ecfac10949
5 changed files with 340 additions and 166 deletions

View File

@@ -7,16 +7,17 @@ import { localize } from 'vs/nls';
import { Action } from 'vs/base/common/actions';
import { INotebookEditor, INotebookService } from 'sql/workbench/services/notebook/browser/notebookService';
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget';
import { IRange } from 'vs/editor/common/core/range';
import { IRange, Range } from 'vs/editor/common/core/range';
import { IIdentifiedSingleEditOperation } from 'vs/editor/common/model';
import { TextModel } from 'vs/editor/common/model/textModel';
import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
import { QueryTextEditor } from 'sql/workbench/browser/modelComponents/queryTextEditor';
import { Selection } from 'vs/editor/common/core/selection';
import { ToggleableAction } from 'sql/workbench/contrib/notebook/browser/notebookActions';
import { EditOperation } from 'vs/editor/common/core/editOperation';
import { Position } from 'vs/editor/common/core/position';
// Action to decorate markdown
export class TransformMarkdownAction extends Action {
constructor(
@@ -66,128 +67,39 @@ export class MarkdownTextTransformer {
endLineNumber: selection.startLineNumber
};
// Get text to insert before selection
let beginInsertedCode = this.getStartTextToInsert(type);
// Get text to insert after selection
let endInsertedCode = this.getEndTextToInsert(type);
let beginInsertedText = getStartTextToInsert(type);
let endInsertedText = getEndTextToInsert(type);
// endInsertedCode can be an empty string (e.g. for unordered list), so no need to check for that as well
if (beginInsertedCode) {
let endRange: IRange = {
startColumn: selection.endColumn,
endColumn: selection.endColumn,
startLineNumber: selection.endLineNumber,
endLineNumber: selection.endLineNumber
};
let editorModel = editorControl.getModel() as TextModel;
let isUndo = false;
if (editorModel) {
let markdownLineType = this.getMarkdownLineType(type);
isUndo = this.isUndoOperation(selection, type, markdownLineType, editorModel);
if (isUndo) {
this.handleUndoOperation(markdownLineType, startRange, endRange, editorModel, beginInsertedCode, endInsertedCode, selections, selection);
} else {
this.handleTransformOperation(markdownLineType, startRange, endRange, editorModel, beginInsertedCode, endInsertedCode, selections, selection);
}
let endRange: IRange = {
startColumn: selection.endColumn,
endColumn: selection.endColumn,
startLineNumber: selection.endLineNumber,
endLineNumber: selection.endLineNumber
};
let editorModel = editorControl.getModel() as TextModel;
let isUndo = false;
if (editorModel) {
let markdownLineType = getMarkdownLineType(type);
// Paragraph (empty string) is just used for replacing any existing headers so will never be an undo operation
isUndo = beginInsertedText && this.isUndoOperation(selection, type, markdownLineType, editorModel);
if (isUndo) {
this.handleUndoOperation(markdownLineType, startRange, endRange, editorModel, beginInsertedText, endInsertedText, selections, selection);
} else {
this.handleTransformOperation(markdownLineType, type, startRange, endRange, editorModel, beginInsertedText, endInsertedText, selections, selection);
}
// If selection end is on same line as beginning, need to add offset for number of characters inserted
// Otherwise, the selection will not be correct after the transformation
let offset = selection.startLineNumber === selection.endLineNumber ? beginInsertedCode.length : 0;
endRange = this.getIRangeWithOffsets(endRange, offset, 0, offset, 0);
this.setEndSelection(endRange, type, editorControl, editorModel, nothingSelected, isUndo);
}
// If selection end is on same line as beginning, need to add offset for number of characters inserted
// Otherwise, the selection will not be correct after the transformation
let offset = selection.startLineNumber === selection.endLineNumber ? beginInsertedText.length : 0;
endRange = this.getIRangeWithOffsets(endRange, offset, 0, offset, 0);
this.setEndSelection(endRange, type, editorControl, editorModel, nothingSelected, isUndo);
// Always give focus back to the editor after pressing the button
editorControl.focus();
}
}
// For items like lists (where we need to insert a character at the beginning of each line), create
// range object for that range
private transformRangeByLineOffset(range: IRange, lineOffset: number): IRange {
return {
startColumn: lineOffset === 0 ? range.startColumn : 1,
endColumn: range.endColumn,
startLineNumber: range.endLineNumber + lineOffset,
endLineNumber: range.endLineNumber + lineOffset
};
}
private getStartTextToInsert(type: MarkdownButtonType): string {
switch (type) {
case MarkdownButtonType.BOLD:
return '**';
case MarkdownButtonType.ITALIC:
return '_';
case MarkdownButtonType.UNDERLINE:
return '<u>';
case MarkdownButtonType.CODE:
return '```\n';
case MarkdownButtonType.LINK:
return '[';
case MarkdownButtonType.UNORDERED_LIST:
return '- ';
case MarkdownButtonType.ORDERED_LIST:
return '1. ';
case MarkdownButtonType.IMAGE:
return '![';
case MarkdownButtonType.HIGHLIGHT:
return '<mark>';
default:
return '';
}
}
private getEndTextToInsert(type: MarkdownButtonType): string {
switch (type) {
case MarkdownButtonType.BOLD:
return '**';
case MarkdownButtonType.ITALIC:
return '_';
case MarkdownButtonType.UNDERLINE:
return '</u>';
case MarkdownButtonType.CODE:
return '\n```';
case MarkdownButtonType.LINK:
case MarkdownButtonType.IMAGE:
return ']()';
case MarkdownButtonType.HIGHLIGHT:
return '</mark>';
case MarkdownButtonType.UNORDERED_LIST:
case MarkdownButtonType.ORDERED_LIST:
default:
return '';
}
}
private getMarkdownLineType(type: MarkdownButtonType): MarkdownLineType {
switch (type) {
case MarkdownButtonType.UNORDERED_LIST:
case MarkdownButtonType.ORDERED_LIST:
return MarkdownLineType.EVERY_LINE;
case MarkdownButtonType.CODE:
return MarkdownLineType.WRAPPED_ABOVE_AND_BELOW;
default:
return MarkdownLineType.BEGIN_AND_END_LINES;
}
}
// Get offset from the end column for editor selection
// For example, when inserting a link, we want to have the cursor be present in between the brackets
private getColumnOffsetForSelection(type: MarkdownButtonType, nothingSelected: boolean): number {
if (nothingSelected) {
return 0;
}
switch (type) {
case MarkdownButtonType.LINK:
return 2;
case MarkdownButtonType.IMAGE:
return 2;
// -1 is considered as having no explicit offset, so do not do anything with selection
default: return -1;
}
}
private getEditorControl(): CodeEditorWidget | undefined {
if (!this._notebookEditor) {
this._notebookEditor = this._notebookService.findNotebookEditor(this._cellModel?.notebookModel?.notebookUri);
@@ -221,7 +133,7 @@ export class MarkdownTextTransformer {
if (!endRange || !editorControl || isUndo) {
return;
}
let offset = this.getColumnOffsetForSelection(type, noSelection);
let offset = getColumnOffsetForSelection(type, noSelection);
if (offset > -1) {
let newRange: IRange;
if (type !== MarkdownButtonType.CODE) {
@@ -241,13 +153,13 @@ export class MarkdownTextTransformer {
}
editorControl.setSelection(newRange);
} else {
let markdownLineType = this.getMarkdownLineType(type);
let markdownLineType = getMarkdownLineType(type);
let currentSelection = editorControl.getSelection();
if (markdownLineType === MarkdownLineType.BEGIN_AND_END_LINES) {
editorControl.setSelection({
startColumn: currentSelection.startColumn + this.getStartTextToInsert(type).length,
startColumn: currentSelection.startColumn + getStartTextToInsert(type).length,
startLineNumber: currentSelection.startLineNumber,
endColumn: currentSelection.endColumn - this.getEndTextToInsert(type).length,
endColumn: currentSelection.endColumn - getEndTextToInsert(type).length,
endLineNumber: currentSelection.endLineNumber
});
} else if (markdownLineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) {
@@ -272,8 +184,8 @@ export class MarkdownTextTransformer {
*/
private isUndoOperation(selection: Selection, type: MarkdownButtonType, lineType: MarkdownLineType, editorModel: TextModel): boolean {
if (lineType === MarkdownLineType.BEGIN_AND_END_LINES || lineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) {
let selectedText = this.getExtendedSelectedText(selection, type, lineType, editorModel);
return selectedText && selectedText.startsWith(this.getStartTextToInsert(type)) && selectedText.endsWith(this.getEndTextToInsert(type));
const selectedText = this.getExtendedSelectedText(selection, type, lineType, editorModel);
return selectedText && selectedText.startsWith(getStartTextToInsert(type)) && selectedText.endsWith(getEndTextToInsert(type));
} else {
return this.everyLineMatchesBeginString(selection, type, editorModel);
}
@@ -283,32 +195,53 @@ export class MarkdownTextTransformer {
if (markdownLineType === MarkdownLineType.BEGIN_AND_END_LINES) {
startRange = this.getIRangeWithOffsets(startRange, -1 * beginInsertedCode.length, 0, 0, 0);
endRange = this.getIRangeWithOffsets(endRange, 0, 0, endInsertedCode.length, 0);
editorModel.pushEditOperations(selections, [{ range: endRange, text: '' }, { range: startRange, text: '' }], null);
editorModel.pushEditOperations(selections, [{ range: endRange, text: '' }, { range: startRange, text: '' }], undefined);
} else if (markdownLineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) {
startRange = this.getIRangeWithOffsets(startRange, 0, -1, 0, 0);
endRange = this.getIRangeWithOffsets(endRange, 0, 0, endInsertedCode.length, 1);
editorModel.pushEditOperations(selections, [{ range: endRange, text: '' }, { range: startRange, text: '' }], null);
} else {
// Delete the entire rows above and below the current selection
const startLineRange = new Range(startRange.startLineNumber - 1, 1, startRange.startLineNumber, 1);
const endLineRange = new Range(endRange.startLineNumber, editorModel.getLineLength(endRange.startLineNumber) + 1, endRange.startLineNumber + 1, endInsertedCode.length + 1);
editorModel.pushEditOperations(selections, [EditOperation.delete(endLineRange), EditOperation.delete(startLineRange)], undefined);
} else if (markdownLineType === MarkdownLineType.EVERY_LINE) {
let operations: IIdentifiedSingleEditOperation[] = [];
startRange = this.getIRangeWithOffsets(startRange, 0, 0, beginInsertedCode.length, 0);
for (let i = 0; i < selection.endLineNumber - selection.startLineNumber + 1; i++) {
operations.push({ range: this.transformRangeByLineOffset(startRange, i), text: '' });
for (let i = selection.startLineNumber; i <= selection.endLineNumber; i++) {
// If we're in an undo operation we already verified that every line starts with the expected text
// Create the edit operation to delete the text for every line
operations.push(EditOperation.delete(new Range(i, 1, i, beginInsertedCode.length + 1)));
}
editorModel.pushEditOperations(selections, operations, null);
editorModel.pushEditOperations(selections, operations, undefined);
}
}
handleTransformOperation(markdownLineType: MarkdownLineType, startRange: IRange, endRange: IRange, editorModel: TextModel, beginInsertedCode: string, endInsertedCode: string, selections: Selection[], selection: Selection): void {
private handleTransformOperation(markdownLineType: MarkdownLineType, markdownButtonType: MarkdownButtonType, startRange: IRange, endRange: IRange, editorModel: TextModel, beginInsertedCode: string, endInsertedCode: string, selections: Selection[], selection: Selection): void {
// If the markdown we're inserting only needs to be added to the begin and end lines, add those edit operations directly
if (markdownLineType === MarkdownLineType.BEGIN_AND_END_LINES || markdownLineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) {
editorModel.pushEditOperations(selections, [{ range: startRange, text: beginInsertedCode }, { range: endRange, text: endInsertedCode }], null);
} else { // Otherwise, add an operation per line (plus the operation at the last column + line)
let operations: IIdentifiedSingleEditOperation[] = [];
for (let i = 0; i < selection.endLineNumber - selection.startLineNumber + 1; i++) {
operations.push({ range: this.transformRangeByLineOffset(startRange, i), text: beginInsertedCode });
editorModel.pushEditOperations(selections, [
{ range: startRange, text: beginInsertedCode },
{ range: endRange, text: endInsertedCode }], undefined);
} else if (markdownLineType === MarkdownLineType.EVERY_LINE) {
const replacementTokens = getStartTextToReplace(markdownButtonType);
const operations: IIdentifiedSingleEditOperation[] = [];
// Create the edit operation to insert the text for every line
for (let i = selection.startLineNumber; i <= selection.endLineNumber; i++) {
// If this token is part of a group then see if the text for this line
// starts with any of the tokens in that group
const replacementText = replacementTokens.find(t => {
const text = editorModel.getValueInRange({
startColumn: 1,
startLineNumber: i,
endColumn: t.length + 1,
endLineNumber: i
});
return text === t;
});
// If we have text to replace do that - otherwise just insert the text directly
if (replacementText) {
operations.push(EditOperation.replace(new Range(i, 1, i, replacementText.length + 1), beginInsertedCode));
} else {
operations.push(EditOperation.insert(new Position(i, 0), beginInsertedCode));
}
}
operations.push({ range: endRange, text: endInsertedCode });
editorModel.pushEditOperations(selections, operations, null);
editorModel.pushEditOperations(selections, operations, undefined);
}
}
@@ -322,16 +255,16 @@ export class MarkdownTextTransformer {
private getExtendedSelectedText(selection: Selection, type: MarkdownButtonType, lineType: MarkdownLineType, editorModel: TextModel): string {
if (lineType === MarkdownLineType.BEGIN_AND_END_LINES) {
return editorModel.getValueInRange({
startColumn: selection.startColumn - this.getStartTextToInsert(type).length,
startColumn: selection.startColumn - getStartTextToInsert(type).length,
startLineNumber: selection.startLineNumber,
endColumn: selection.endColumn + this.getEndTextToInsert(type).length,
endColumn: selection.endColumn + getEndTextToInsert(type).length,
endLineNumber: selection.endLineNumber
});
} else if (lineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) {
return editorModel.getValueInRange({
startColumn: 1,
startLineNumber: selection.startLineNumber - 1,
endColumn: this.getEndTextToInsert(type).length + 1,
endColumn: getEndTextToInsert(type).length + 1,
endLineNumber: selection.endLineNumber + 1
});
}
@@ -346,7 +279,7 @@ export class MarkdownTextTransformer {
*/
private everyLineMatchesBeginString(selection: Selection, type: MarkdownButtonType, editorModel: TextModel): boolean {
for (let selectionLine = selection.startLineNumber; selectionLine <= selection.endLineNumber; selectionLine++) {
if (!editorModel.getLineContent(selectionLine).startsWith(this.getStartTextToInsert(type))) {
if (!editorModel.getLineContent(selectionLine).startsWith(getStartTextToInsert(type))) {
return false;
}
}
@@ -380,17 +313,166 @@ export enum MarkdownButtonType {
LINK,
UNORDERED_LIST,
ORDERED_LIST,
IMAGE
IMAGE,
HEADING1,
HEADING2,
HEADING3,
PARAGRAPH
}
// If ALL_LINES, we need to insert markdown at each line (e.g. lists)
// WRAPPED_ABOVE_AND_BELOW puts text above and below the highlighted text
/**
* The line types
*/
export enum MarkdownLineType {
/**
* Applies to the beginning and end lines only of a selection
*/
BEGIN_AND_END_LINES,
/**
* Applies to every line within the selection
*/
EVERY_LINE,
/**
* Applies to the entire selection by wrapping it in new text above and below
*/
WRAPPED_ABOVE_AND_BELOW
}
/**
* Groups of related types - these will be considered as the same when doing a transformation
* so will replace each other instead of just prepending new text if the line already starts
* with the text for another member of the group.
*/
const buttonTypeGroups = [
[
MarkdownButtonType.HEADING1,
MarkdownButtonType.HEADING2,
MarkdownButtonType.HEADING3,
MarkdownButtonType.PARAGRAPH
]
];
/**
* Gets the list of strings that will be replaced if a selection starts
* with the specified text
* @param type The button type action being ran
*/
function getStartTextToReplace(type: MarkdownButtonType): string[] {
for (const group of buttonTypeGroups) {
const item = group.find(value => value === type);
if (item) {
return group.filter(item => item !== type).map(item => getStartTextToInsert(item));
}
}
return [];
}
/**
* Gets the text to insert at the beginning of the selection
* @param type The button type action being ran
*/
function getStartTextToInsert(type: MarkdownButtonType): string {
switch (type) {
case MarkdownButtonType.BOLD:
return '**';
case MarkdownButtonType.ITALIC:
return '_';
case MarkdownButtonType.UNDERLINE:
return '<u>';
case MarkdownButtonType.CODE:
return '```\n';
case MarkdownButtonType.LINK:
return '[';
case MarkdownButtonType.UNORDERED_LIST:
return '- ';
case MarkdownButtonType.ORDERED_LIST:
return '1. ';
case MarkdownButtonType.IMAGE:
return '![';
case MarkdownButtonType.HIGHLIGHT:
return '<mark>';
case MarkdownButtonType.HEADING1:
return '# ';
case MarkdownButtonType.HEADING2:
return '## ';
case MarkdownButtonType.HEADING3:
return '### ';
default:
return '';
}
}
/**
* Gets the text to insert at the end of the selection
* @param type The button type action being ran
*/
function getEndTextToInsert(type: MarkdownButtonType): string {
switch (type) {
case MarkdownButtonType.BOLD:
return '**';
case MarkdownButtonType.ITALIC:
return '_';
case MarkdownButtonType.UNDERLINE:
return '</u>';
case MarkdownButtonType.CODE:
return '\n```';
case MarkdownButtonType.LINK:
case MarkdownButtonType.IMAGE:
return ']()';
case MarkdownButtonType.HIGHLIGHT:
return '</mark>';
case MarkdownButtonType.UNORDERED_LIST:
case MarkdownButtonType.ORDERED_LIST:
case MarkdownButtonType.HEADING1:
case MarkdownButtonType.HEADING2:
case MarkdownButtonType.HEADING3:
case MarkdownButtonType.PARAGRAPH:
default:
return '';
}
}
/**
* Gets the line type that a button type applies to
* @param type The button type action being ran
*/
function getMarkdownLineType(type: MarkdownButtonType): MarkdownLineType {
switch (type) {
case MarkdownButtonType.CODE:
return MarkdownLineType.WRAPPED_ABOVE_AND_BELOW;
case MarkdownButtonType.UNORDERED_LIST:
case MarkdownButtonType.ORDERED_LIST:
case MarkdownButtonType.HEADING1:
case MarkdownButtonType.HEADING2:
case MarkdownButtonType.HEADING3:
case MarkdownButtonType.PARAGRAPH:
return MarkdownLineType.EVERY_LINE;
default:
return MarkdownLineType.BEGIN_AND_END_LINES;
}
}
/**
* Get offset from the end column for editor selection
* For example, when inserting a link, we want to have the cursor be present in between the brackets
* @param type
* @param nothingSelected
*/
function getColumnOffsetForSelection(type: MarkdownButtonType, nothingSelected: boolean): number {
if (nothingSelected) {
return 0;
}
switch (type) {
case MarkdownButtonType.LINK:
return 2;
case MarkdownButtonType.IMAGE:
return 2;
// -1 is considered as having no explicit offset, so do not do anything with selection
default: return -1;
}
}
export class TogglePreviewAction extends ToggleableAction {
private static readonly previewShowLabel = localize('previewShowLabel', "Show Preview");