From ecfac109491000d3af8f8a724681edc6125dfece Mon Sep 17 00:00:00 2001 From: Hale Rankin Date: Thu, 2 Jul 2020 17:30:29 -0700 Subject: [PATCH] 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 --- .../cellViews/markdownToolbar.component.ts | 36 +- .../browser/cellViews/markdownToolbar.css | 15 +- .../browser/cellViews/textCell.component.ts | 8 +- .../browser/markdownToolbarActions.ts | 374 +++++++++++------- ...est.ts => markdownTextTransformer.test.ts} | 73 +++- 5 files changed, 340 insertions(+), 166 deletions(-) rename src/sql/workbench/contrib/notebook/test/browser/{MarkdownTextTransformer.test.ts => markdownTextTransformer.test.ts} (76%) diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts index a87c5fa40b..022ce5340f 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component.ts @@ -3,12 +3,15 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import 'vs/css!./markdownToolbar'; +import * as DOM from 'vs/base/browser/dom'; import { Component, Input, Inject, ViewChild, ElementRef } from '@angular/core'; import { localize } from 'vs/nls'; import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces'; import { Taskbar } from 'sql/base/browser/ui/taskbar/taskbar'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { TransformMarkdownAction, MarkdownButtonType, TogglePreviewAction } from 'sql/workbench/contrib/notebook/browser/markdownToolbarActions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { DropdownMenuActionViewItem } from 'sql/base/browser/ui/buttonMenu/buttonMenu'; export const MARKDOWN_TOOLBAR_SELECTOR: string = 'markdown-toolbar-component'; @@ -28,12 +31,19 @@ export class MarkdownToolbarComponent { public buttonList = localize('buttonList', "List"); public buttonOrderedList = localize('buttonOrderedList', "Ordered list"); public buttonImage = localize('buttonImage', "Image"); + public buttonPreview = localize('buttonPreview', "Markdown preview toggle - off"); + public dropdownHeading = localize('dropdownHeading', "Heading"); + public optionHeading1 = localize('optionHeading1', "Heading 1"); + public optionHeading2 = localize('optionHeading2', "Heading 2"); + public optionHeading3 = localize('optionHeading3', "Heading 3"); + public optionParagraph = localize('optionParagraph', "Paragraph"); @Input() public cellModel: ICellModel; private _actionBar: Taskbar; constructor( - @Inject(IInstantiationService) private _instantiationService: IInstantiationService + @Inject(IInstantiationService) private _instantiationService: IInstantiationService, + @Inject(IContextMenuService) private contextMenuService: IContextMenuService ) { } ngOnInit() { @@ -52,9 +62,32 @@ export class MarkdownToolbarComponent { let imageButton = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.imageText', '', 'insert-image', this.buttonImage, this.cellModel, MarkdownButtonType.IMAGE); let togglePreviewAction = this._instantiationService.createInstance(TogglePreviewAction, 'notebook.togglePreview', true, this.cellModel.showPreview); + let headingDropdown = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.heading', '', 'heading', this.dropdownHeading, this.cellModel, null); + let heading1 = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.heading1', this.optionHeading1, 'heading 1', this.optionHeading1, this.cellModel, MarkdownButtonType.HEADING1); + let heading2 = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.heading2', this.optionHeading2, 'heading 2', this.optionHeading2, this.cellModel, MarkdownButtonType.HEADING2); + let heading3 = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.heading3', this.optionHeading3, 'heading 3', this.optionHeading3, this.cellModel, MarkdownButtonType.HEADING3); + let paragraph = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.paragraph', this.optionParagraph, 'paragraph', this.optionParagraph, this.cellModel, MarkdownButtonType.PARAGRAPH); + let taskbar = this.mdtoolbar.nativeElement; this._actionBar = new Taskbar(taskbar); this._actionBar.context = this; + + let buttonDropdownContainer = DOM.$('li.action-item'); + buttonDropdownContainer.setAttribute('role', 'presentation'); + let dropdownMenuActionViewItem = new DropdownMenuActionViewItem( + headingDropdown, + [heading1, heading2, heading3, paragraph], + this.contextMenuService, + undefined, + this._actionBar.actionRunner, + undefined, + 'notebook-button masked-pseudo-after dropdown-arrow', + this.optionParagraph, + undefined + ); + dropdownMenuActionViewItem.render(buttonDropdownContainer); + dropdownMenuActionViewItem.setActionContext(this); + this._actionBar.setContent([ { action: boldButton }, { action: italicButton }, @@ -65,6 +98,7 @@ export class MarkdownToolbarComponent { { action: listButton }, { action: orderedListButton }, { action: imageButton }, + { element: buttonDropdownContainer }, { action: togglePreviewAction } ]); } diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.css b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.css index bcc90756a8..f2a452da46 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.css +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.css @@ -34,13 +34,26 @@ .markdown-toolbar .carbon-taskbar li:nth-child(2) { margin-right: 9px; } +.markdown-toolbar .carbon-taskbar li.action-item .masked-pseudo-after.dropdown-arrow { + background-color: transparent; + font-size: 14px; + height: 100%; + line-height: 20px; + width: 100%; +} +.markdown-toolbar .carbon-taskbar li.action-item .masked-pseudo-after.dropdown-arrow:after { + position: relative; + right: -6px; + top: -3px; + width: 26px; +} .markdown-toolbar .carbon-taskbar li:last-child { margin-right: 0; position: absolute; right: 0; } .markdown-toolbar .carbon-taskbar li a { - display: inline-block; + display: flex; height: 20px; width: 20px; -webkit-mask-position: center; diff --git a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts index 4b624f0ff2..a6a760be26 100644 --- a/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts +++ b/src/sql/workbench/contrib/notebook/browser/cellViews/textCell.component.ts @@ -199,9 +199,11 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges { }); this.markdownResult.element.innerHTML = this.sanitizeContent(this.markdownResult.element.innerHTML); this.setLoading(false); - let outputElement = this.output.nativeElement; - outputElement.innerHTML = this.markdownResult.element.innerHTML; - this.cellModel.renderedOutputTextContent = this.getRenderedTextOutput(); + if (this.showPreview) { + let outputElement = this.output.nativeElement; + outputElement.innerHTML = this.markdownResult.element.innerHTML; + this.cellModel.renderedOutputTextContent = this.getRenderedTextOutput(); + } } } diff --git a/src/sql/workbench/contrib/notebook/browser/markdownToolbarActions.ts b/src/sql/workbench/contrib/notebook/browser/markdownToolbarActions.ts index 7e9eff07bc..df8a78eafa 100644 --- a/src/sql/workbench/contrib/notebook/browser/markdownToolbarActions.ts +++ b/src/sql/workbench/contrib/notebook/browser/markdownToolbarActions.ts @@ -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 ''; - 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 ''; - default: - return ''; - } - } - - private getEndTextToInsert(type: MarkdownButtonType): string { - switch (type) { - case MarkdownButtonType.BOLD: - return '**'; - case MarkdownButtonType.ITALIC: - return '_'; - case MarkdownButtonType.UNDERLINE: - return ''; - case MarkdownButtonType.CODE: - return '\n```'; - case MarkdownButtonType.LINK: - case MarkdownButtonType.IMAGE: - return ']()'; - case MarkdownButtonType.HIGHLIGHT: - return ''; - 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 ''; + 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 ''; + 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 ''; + case MarkdownButtonType.CODE: + return '\n```'; + case MarkdownButtonType.LINK: + case MarkdownButtonType.IMAGE: + return ']()'; + case MarkdownButtonType.HIGHLIGHT: + return ''; + 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"); diff --git a/src/sql/workbench/contrib/notebook/test/browser/MarkdownTextTransformer.test.ts b/src/sql/workbench/contrib/notebook/test/browser/markdownTextTransformer.test.ts similarity index 76% rename from src/sql/workbench/contrib/notebook/test/browser/MarkdownTextTransformer.test.ts rename to src/sql/workbench/contrib/notebook/test/browser/markdownTextTransformer.test.ts index 3e54b1ae28..51c2b3e6ef 100644 --- a/src/sql/workbench/contrib/notebook/test/browser/MarkdownTextTransformer.test.ts +++ b/src/sql/workbench/contrib/notebook/test/browser/markdownTextTransformer.test.ts @@ -30,8 +30,7 @@ import { IEnvironmentService } from 'vs/platform/environment/common/environment' import { IStorageService } from 'vs/platform/storage/common/storage'; import { IEditor } from 'vs/editor/common/editorCommon'; import { NotebookEditorStub } from 'sql/workbench/contrib/notebook/test/testCommon'; - - +import { Range } from 'vs/editor/common/core/range'; suite('MarkdownTextTransformer', () => { let markdownTextTransformer: MarkdownTextTransformer; @@ -74,7 +73,6 @@ suite('MarkdownTextTransformer', () => { // Couple widget with newly created text model widget.setModel(textModel); - // let textModel = widget.getModel() as TextModel; assert(!isUndefinedOrNull(widget.getModel()), 'Text model is undefined'); }); @@ -90,22 +88,21 @@ suite('MarkdownTextTransformer', () => { testWithNoSelection(MarkdownButtonType.LINK, '[]()', true); testWithNoSelection(MarkdownButtonType.LINK, ''); testWithNoSelection(MarkdownButtonType.UNORDERED_LIST, '- ', true); - testWithNoSelection(MarkdownButtonType.UNORDERED_LIST, '- '); + testWithNoSelection(MarkdownButtonType.UNORDERED_LIST, ''); testWithNoSelection(MarkdownButtonType.ORDERED_LIST, '1. ', true); - testWithNoSelection(MarkdownButtonType.ORDERED_LIST, '1. '); + testWithNoSelection(MarkdownButtonType.ORDERED_LIST, ''); testWithNoSelection(MarkdownButtonType.IMAGE, '![]()', true); testWithNoSelection(MarkdownButtonType.IMAGE, ''); + testWithNoSelection(MarkdownButtonType.HEADING1, '# ', true); + testWithNoSelection(MarkdownButtonType.HEADING1, ''); + testWithNoSelection(MarkdownButtonType.HEADING2, '## ', true); + testWithNoSelection(MarkdownButtonType.HEADING2, ''); + testWithNoSelection(MarkdownButtonType.HEADING3, '### ', true); + testWithNoSelection(MarkdownButtonType.HEADING3, ''); }); test('Transform text with one word selected', () => { - testWithSingleWordSelected(MarkdownButtonType.BOLD, '**WORD**'); - testWithSingleWordSelected(MarkdownButtonType.ITALIC, '_WORD_'); testWithSingleWordSelected(MarkdownButtonType.CODE, '```\nWORD\n```'); - testWithSingleWordSelected(MarkdownButtonType.HIGHLIGHT, 'WORD'); - testWithSingleWordSelected(MarkdownButtonType.LINK, '[WORD]()'); - testWithSingleWordSelected(MarkdownButtonType.UNORDERED_LIST, '- WORD'); - testWithSingleWordSelected(MarkdownButtonType.ORDERED_LIST, '1. WORD'); - testWithSingleWordSelected(MarkdownButtonType.IMAGE, '![WORD]()'); }); test('Transform text with multiple words selected', () => { @@ -128,6 +125,9 @@ suite('MarkdownTextTransformer', () => { testWithMultipleLinesSelected(MarkdownButtonType.UNORDERED_LIST, '- Multi\n- Lines\n- Selected'); testWithMultipleLinesSelected(MarkdownButtonType.ORDERED_LIST, '1. Multi\n1. Lines\n1. Selected'); testWithMultipleLinesSelected(MarkdownButtonType.IMAGE, '![Multi\nLines\nSelected]()'); + testWithMultipleLinesSelected(MarkdownButtonType.HEADING1, '# Multi\n# Lines\n# Selected'); + testWithMultipleLinesSelected(MarkdownButtonType.HEADING2, '## Multi\n## Lines\n## Selected'); + testWithMultipleLinesSelected(MarkdownButtonType.HEADING3, '### Multi\n### Lines\n### Selected'); }); test('Ensure notebook editor returns expected object', () => { @@ -146,16 +146,26 @@ suite('MarkdownTextTransformer', () => { textModel.setValue(''); } markdownTextTransformer.transformText(type); - assert.equal(textModel.getValue(), expectedValue, `${MarkdownButtonType[type]} with no selection failed`); + assert.equal(textModel.getValue(), expectedValue, `${MarkdownButtonType[type]} with no selection failed (setValue ${setValue})`); } function testWithSingleWordSelected(type: MarkdownButtonType, expectedValue: string): void { let value = 'WORD'; textModel.setValue(value); - widget.setSelection({ startColumn: 1, startLineNumber: 1, endColumn: 5, endLineNumber: 1 }); + + // Test transformation (adding text) + widget.setSelection({ startColumn: 1, startLineNumber: 1, endColumn: value.length + 1, endLineNumber: 1 }); assert.equal(textModel.getValueInRange(widget.getSelection()), value, 'Expected selection is not found'); markdownTextTransformer.transformText(type); - assert.equal(textModel.getValue(), expectedValue, `${MarkdownButtonType[type]} with single word selection failed`); + const textModelValue = textModel.getValue(); + assert.equal(textModelValue, expectedValue, `${MarkdownButtonType[type]} with single word selection failed`); + + // Test undo (removing text) + const valueRange = getValueRange(textModel, value); + assert.notEqual(valueRange, undefined, 'Could not find value in model after transformation'); + widget.setSelection(valueRange); + markdownTextTransformer.transformText(type); + assert.equal(textModel.getValue(), value, `Undo operation for ${MarkdownButtonType[type]} with single word selection failed`); } function testWithMultipleWordsSelected(type: MarkdownButtonType, expectedValue: string): void { @@ -165,6 +175,13 @@ suite('MarkdownTextTransformer', () => { assert.equal(textModel.getValueInRange(widget.getSelection()), value, 'Expected multi-word selection is not found'); markdownTextTransformer.transformText(type); assert.equal(textModel.getValue(), expectedValue, `${MarkdownButtonType[type]} with multiple word selection failed`); + + // Test undo (removing text) + const valueRange = getValueRange(textModel, value); + assert.notEqual(valueRange, undefined, 'Could not find value in model after transformation'); + widget.setSelection(valueRange); + markdownTextTransformer.transformText(type); + assert.equal(textModel.getValue(), value, `Undo operation for ${MarkdownButtonType[type]} with multiple word selection failed`); } function testWithMultipleLinesSelected(type: MarkdownButtonType, expectedValue: string): void { @@ -174,6 +191,32 @@ suite('MarkdownTextTransformer', () => { assert.equal(textModel.getValueInRange(widget.getSelection()), value, 'Expected multi-line selection is not found'); markdownTextTransformer.transformText(type); assert.equal(textModel.getValue(), expectedValue, `${MarkdownButtonType[type]} with multiple line selection failed`); + + // Test undo (removing text) + let valueRange = getValueRange(textModel, 'Multi'); + // Modify the range to include all the lines + valueRange = new Range(valueRange.startLineNumber, valueRange.startColumn, valueRange.endLineNumber + 2, 9); + assert.notEqual(valueRange, undefined, 'Could not find value in model after transformation'); + widget.setSelection(valueRange); + markdownTextTransformer.transformText(type); + assert.equal(textModel.getValue(), value, `Undo operation for ${MarkdownButtonType[type]} with multiple line selection failed`); } }); +/** + * Searches the model for the specified string value and if found returns a range for the last + * occurence of that value. + * @param textModel The model to search + * @param value The value to search for + */ +function getValueRange(textModel: TextModel, value: string): Range | undefined { + const linesContent = textModel.getLinesContent(); + let range = undefined; + linesContent.forEach((line, index) => { + const valueIndex = line.indexOf(value); + if (valueIndex >= 0) { + range = new Range(index + 1, valueIndex + 1, index + 1, valueIndex + value.length + 1); + } + }); + return range; +}