/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ 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, 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 { EditOperation } from 'vs/editor/common/core/editOperation'; import { Position } from 'vs/editor/common/core/position'; import { MarkdownToolbarComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/markdownToolbar.component'; import { IEditor } from 'vs/editor/common/editorCommon'; export class TransformMarkdownAction extends Action { constructor( id: string, label: string, cssClass: string, tooltip: string, private _cellModel: ICellModel, private _type: MarkdownButtonType, @INotebookService private _notebookService: INotebookService ) { super(id, label, cssClass); this._tooltip = tooltip; } public override async run(context: any): Promise { if (!context?.cellModel?.showMarkdown && context?.cellModel?.showPreview) { this.transformDocumentCommand(); } else { let markdownTextTransformer = new MarkdownTextTransformer(this._notebookService, this._cellModel); await markdownTextTransformer.transformText(this._type); } } private transformDocumentCommand() { switch (this._type) { case MarkdownButtonType.BOLD: document.execCommand('bold'); break; case MarkdownButtonType.CODE: document.execCommand('formatBlock', false, 'pre'); break; case MarkdownButtonType.HEADING1: document.execCommand('formatBlock', false, 'H1'); break; case MarkdownButtonType.HEADING2: document.execCommand('formatBlock', false, 'H2'); break; case MarkdownButtonType.HEADING3: document.execCommand('formatBlock', false, 'H3'); break; case MarkdownButtonType.HIGHLIGHT: let selectionFocusNode = document.getSelection()?.focusNode; // Find if element is wrapped in while (selectionFocusNode?.parentNode?.nodeName?.toLowerCase() && selectionFocusNode?.parentNode?.nodeName?.toLowerCase() !== 'mark') { selectionFocusNode = selectionFocusNode.parentNode; } // Find if element is wrapped in if (selectionFocusNode?.parentNode?.nodeName?.toLowerCase() !== 'mark') { selectionFocusNode = document.getSelection()?.focusNode; while (selectionFocusNode?.parentNode?.nodeName?.toLowerCase() && selectionFocusNode?.parentNode?.nodeName?.toLowerCase() !== 'span' && selectionFocusNode?.parentElement?.style?.backgroundColor !== 'yellow') { selectionFocusNode = selectionFocusNode.parentNode; } } let nodeName = selectionFocusNode?.parentNode?.nodeName?.toLowerCase(); let backgroundColor = selectionFocusNode?.parentElement?.style?.backgroundColor; if (nodeName === 'mark') { let oldParent = selectionFocusNode.parentNode; let newParent = selectionFocusNode.parentNode.parentNode; let oldParentNextSibling = oldParent.nextSibling; // Remove mark element, reparent while (oldParent.childNodes.length > 0) { // If no next sibling, then old parent was the final child node, so we can append if (!oldParentNextSibling) { newParent.appendChild(oldParent.firstChild); } else { newParent.insertBefore(oldParent.firstChild, oldParentNextSibling); } } // Empty span required to force an input so that HTML change is seen from text cell component // This span doesn't have any effect on the markdown generated. document.execCommand('formatBlock', false, 'span'); } else if (selectionFocusNode?.parentNode?.nodeName?.toLowerCase() === 'span' && backgroundColor === 'yellow') { selectionFocusNode.parentElement.style.backgroundColor = ''; // Empty span required to force an input so that HTML change is seen from text cell component // This span doesn't have any effect on the markdown generated. document.execCommand('formatBlock', false, 'span'); } else { document.execCommand('hiliteColor', false, 'Yellow'); } break; case MarkdownButtonType.IMAGE: case MarkdownButtonType.IMAGE_PREVIEW: // TODO break; case MarkdownButtonType.ITALIC: document.execCommand('italic'); break; case MarkdownButtonType.LINK: case MarkdownButtonType.LINK_PREVIEW: document.execCommand('createLink', false, window.getSelection()?.focusNode?.textContent); break; case MarkdownButtonType.ORDERED_LIST: document.execCommand('insertOrderedList'); break; case MarkdownButtonType.PARAGRAPH: document.execCommand('formatBlock', false, 'p'); break; case MarkdownButtonType.UNDERLINE: document.execCommand('underline'); break; case MarkdownButtonType.UNORDERED_LIST: document.execCommand('insertUnorderedList'); break; } } } export class MarkdownTextTransformer { constructor( private _notebookService: INotebookService, private _cellModel: ICellModel, private _notebookEditor?: INotebookEditor) { } public get notebookEditor(): INotebookEditor { return this._notebookEditor; } public async transformText(type: MarkdownButtonType): Promise { let editorControl = this.getEditorControl(); if (editorControl) { let selections = editorControl.getSelections(); // TODO: Support replacement for multiple selections let selection = selections[0]; let nothingSelected = this.editorHasNoSelection(selection); let startRange: IRange = { startColumn: selection.startColumn, endColumn: selection.startColumn, startLineNumber: selection.startLineNumber, endLineNumber: selection.startLineNumber }; let beginInsertedText = getStartTextToInsert(type); let endInsertedText = getEndTextToInsert(type); 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 ? 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(); } } private getEditorControl(): CodeEditorWidget | undefined { if (!this._notebookEditor) { this._notebookEditor = this._notebookService.findNotebookEditor(this._cellModel?.notebookModel?.notebookUri); } if (this._notebookEditor?.cellEditors?.length > 0) { // Find cell editor provider via cell guid let cellEditorProvider = this._notebookEditor.cellEditors.find(e => e.cellGuid() === this._cellModel.cellGuid); if (cellEditorProvider) { let editor = cellEditorProvider.getEditor() as QueryTextEditor; if (editor) { let editorControl = editor.getControl() as CodeEditorWidget; return editorControl; } } } return undefined; } private editorHasNoSelection(selection: Selection): boolean { return !selection || (selection.startLineNumber === selection.endLineNumber && selection.startColumn === selection.endColumn); } /** * Sets the end selection state after the transform has occurred * @param endRange range for end text that was inserted * @param type MarkdownButtonType * @param editorControl code editor widget * @param noSelection controls whether there was no previous selection in the editor */ private setEndSelection(endRange: IRange, type: MarkdownButtonType, editorControl: CodeEditorWidget, editorModel: TextModel, noSelection: boolean, isUndo: boolean): void { if (!endRange || !editorControl || isUndo) { return; } let offset = getColumnOffsetForSelection(type, noSelection); if (offset > -1) { let newRange: IRange; if (type !== MarkdownButtonType.CODE) { newRange = { startColumn: endRange.startColumn + offset, startLineNumber: endRange.startLineNumber, endColumn: endRange.startColumn + offset, endLineNumber: endRange.endLineNumber }; } else { newRange = { startColumn: 1, startLineNumber: endRange.startLineNumber + 1, endColumn: 1, endLineNumber: endRange.endLineNumber + 1 }; } editorControl.setSelection(newRange); } else { let markdownLineType = getMarkdownLineType(type); let currentSelection = editorControl.getSelection(); if (markdownLineType === MarkdownLineType.BEGIN_AND_END_LINES) { editorControl.setSelection({ startColumn: currentSelection.startColumn + getStartTextToInsert(type).length, startLineNumber: currentSelection.startLineNumber, endColumn: currentSelection.endColumn - getEndTextToInsert(type).length, endLineNumber: currentSelection.endLineNumber }); } else if (markdownLineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) { // Subtracting 1 because the last line will have the end text (e.g. ``` for code) let endLineLength = editorModel.getLineLength(currentSelection.endLineNumber - 1); editorControl.setSelection({ startColumn: 1, startLineNumber: currentSelection.startLineNumber + 1, endColumn: endLineLength + 1, endLineNumber: currentSelection.endLineNumber - 1 }); } } } /** * Determine if user wants to perform an undo operation * @param selection current user selection * @param type markdown button type * @param lineType markdown line type * @param editorModel text model for the cell */ private isUndoOperation(selection: Selection, type: MarkdownButtonType, lineType: MarkdownLineType, editorModel: TextModel): boolean { if (lineType === MarkdownLineType.BEGIN_AND_END_LINES || lineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) { 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); } } private handleUndoOperation(markdownLineType: MarkdownLineType, startRange: IRange, endRange: IRange, editorModel: TextModel, beginInsertedCode: string, endInsertedCode: string, selections: Selection[], selection: Selection): void { 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: '' }], undefined); } else if (markdownLineType === MarkdownLineType.WRAPPED_ABOVE_AND_BELOW) { // 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[] = []; 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, undefined); } } 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 }], 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)); } } editorModel.pushEditOperations(selections, operations, undefined); } } /** * Gets the extended selected text (current selection + potential beginning + ending transformed text) * @param selection Current selection in editor * @param type Markdown Button Type * @param lineType Markdown Line Type * @param editorModel TextModel */ private getExtendedSelectedText(selection: Selection, type: MarkdownButtonType, lineType: MarkdownLineType, editorModel: TextModel): string { if (lineType === MarkdownLineType.BEGIN_AND_END_LINES) { return editorModel.getValueInRange({ startColumn: selection.startColumn - getStartTextToInsert(type).length, startLineNumber: selection.startLineNumber, 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: getEndTextToInsert(type).length + 1, endLineNumber: selection.endLineNumber + 1 }); } return ''; } /** * Returns whether all lines start with the expected transformed text for actions that match the EVERY_LINE line type * @param selection Current selection in editor * @param type Markdown Button Type * @param editorModel TextModel */ private everyLineMatchesBeginString(selection: Selection, type: MarkdownButtonType, editorModel: TextModel): boolean { for (let selectionLine = selection.startLineNumber; selectionLine <= selection.endLineNumber; selectionLine++) { if (!editorModel.getLineContent(selectionLine).startsWith(getStartTextToInsert(type))) { return false; } } return true; } /** * Create new IRange object with arbitrary offsets * @param initialRange range object * @param startColumnOffset * @param startLineNumberOffset * @param endColumnOffset * @param endLineNumberOffset */ private getIRangeWithOffsets(initialRange: IRange, startColumnOffset = 0, startLineNumberOffset = 0, endColumnOffset = 0, endLineNumberOffset = 0): IRange { return { startColumn: initialRange.startColumn + startColumnOffset, startLineNumber: initialRange.startLineNumber + startLineNumberOffset, endColumn: initialRange.endColumn + endColumnOffset, endLineNumber: initialRange.endLineNumber + endLineNumberOffset }; } } export enum MarkdownButtonType { BOLD, ITALIC, UNDERLINE, CODE, HIGHLIGHT, LINK, LINK_PREVIEW, UNORDERED_LIST, ORDERED_LIST, IMAGE, IMAGE_PREVIEW, HEADING1, HEADING2, HEADING3, PARAGRAPH } /** * 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: case MarkdownButtonType.LINK_PREVIEW: return '['; case MarkdownButtonType.UNORDERED_LIST: return '- '; case MarkdownButtonType.ORDERED_LIST: return '1. '; case MarkdownButtonType.IMAGE: case MarkdownButtonType.IMAGE_PREVIEW: 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.LINK_PREVIEW: case MarkdownButtonType.IMAGE: case MarkdownButtonType.IMAGE_PREVIEW: 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: case MarkdownButtonType.LINK_PREVIEW: return 2; case MarkdownButtonType.IMAGE: case MarkdownButtonType.IMAGE_PREVIEW: return 2; // -1 is considered as having no explicit offset, so do not do anything with selection default: return -1; } } /** * When markdown is already formatted correctly and doesn't need transformed, insert markdown based on current editor selection * @param markdownToInsert formatted markdown * @param editorControl editor control for cell */ export async function insertFormattedMarkdown(markdownToInsert: string, editorControl?: IEditor): Promise { if (editorControl) { let selections = editorControl.getSelections(); let selection = selections[0]; let startRange: IRange = { startColumn: selection.startColumn, endColumn: selection.startColumn, startLineNumber: selection.startLineNumber, endLineNumber: selection.startLineNumber }; let editorModel = editorControl.getModel() as TextModel; startRange = { startColumn: selection.startColumn, endColumn: selection.endColumn, startLineNumber: selection.startLineNumber, endLineNumber: selection.endLineNumber }; editorModel.pushEditOperations(selections, [ { range: startRange, text: markdownToInsert }, ], undefined); } } export class ToggleViewAction extends Action { constructor( id: string, label: string, cssClass: string, tooltip: string, private showPreview: boolean, private showMarkdown: boolean ) { super(id, label, cssClass); this._tooltip = tooltip; } public override async run(context: MarkdownToolbarComponent): Promise { context.removeActiveClassFromModeActions(); this.class += ' active'; context.cellModel.showPreview = this.showPreview; context.cellModel.showMarkdown = this.showMarkdown; // Hide image button in WYSIWYG mode if (this.showPreview && !this.showMarkdown) { context.hideImageButton(); } else { context.showLinkAndImageButtons(); } } }