Hackathon - Better Markdown Editor (#11540)

* Hackathon - better markdown editor - modified Bold to wrap selection in HTML. Split Image button into two new options: embed and link. Made preview container contentEditable.

* Removed the new dropdown from Image button -- it is not necessary since we are adding a context panel instead.

* Modified preview icons

* Set code-component dimensions so it is not visible. It is still being used to pass markdown changes to however.

* add turndown and save markdown

* update model on UI when source changes

* Added conditional that sets element attribute contentEditable when it is in edit mode.

* Added textView component that can be used for editing.

* update source on MD view not on every keystroke

* Added markdown editor buttons that allow user to swap editor, preview views.

* Cleaning up implementation

* Setting base value of _showPreview to false.

* don't allow html edit on split view

* Update editor automagically

* Add an image picking dialog to notebook toolbar.

* Await transformText()

* revert pushEditOperations to fix cursor issue

* Implemented radio buttons for three view toggles.

* Added new, optional properties to radioButton: name, icon class and tooltip. This allows for display as toggleable icon. Updated styles and theme accordingly.

* Style tweaks.

* Added new ViewAction file where the RadioButton action will reside.

* Removed radio button implementation in exchange for native button instantiation. Adjusted CSS and theme accordingly.

* Styles, component and template changes to handle view toggle between text, markdownn an splitview. Includes reverting of radioButton as this is no longer used.

* WYSIWYG 3 Modes

* Ensure one action active at a time

* Setting Text View button active by default. Cleaned up styles. Moved toolbar element to prevent code cell layout overflow.

* Ensure we respect editMode, add showMarkdown

* hiding overflow on code-cell

* Empty text container needs 100% width. Eliminates weird selection border too.

* Initialize _previewMode

* Actions Compatibility

* Further toolbar enhancements

* Update yarn lock after merge

* Slim down changes

* Remove commented out code

* Added margins around notebook-preview container for more visual space for text

* Add turndown to workbench html

* Tweak import

* Add types/turndown

* Remove workbench.html fix

* Import cjs modules directly for turndown

* Leverage solution from github

* browser umd

* non browser umd

* welp dependency

* Modified updatePreview to insert a p tag only when text cell is empty.

* add listener for undo

* add turndown to remote and web

* Fix workbench, check in plugin

* PR comment

Co-authored-by: maddydev <makoripa@microsoft.com>
Co-authored-by: chlafreniere <hichise@gmail.com>
Co-authored-by: Cory Rivera <corivera@microsoft.com>
Co-authored-by: Lucy Zhang <luczhan@microsoft.com>
This commit is contained in:
Hale Rankin
2020-09-11 00:22:07 -07:00
committed by GitHub
parent 0f8fa0ccef
commit 6670289057
25 changed files with 2206 additions and 138 deletions

View File

@@ -208,7 +208,7 @@ export class CodeComponent extends CellView implements OnInit, OnChanges {
let untitledEditorModel = await this._editorInput.resolve() as UntitledTextEditorModel;
this._editorModel = untitledEditorModel.textEditorModel;
this.updateModel();
let isActive = this.cellModel.id === this._activeCellId;
this._editor.toggleEditorSelected(isActive);
@@ -252,8 +252,11 @@ export class CodeComponent extends CellView implements OnInit, OnChanges {
this._register(this.cellModel.onCollapseStateChanged(isCollapsed => {
this.onCellCollapse(isCollapsed);
}));
this._register(this.cellModel.onCellPreviewChanged(() => {
this._register(this.cellModel.onCellPreviewModeChanged((e) => {
if (!e && this._cellModel.cellSourceChanged) {
this.updateModel();
this._cellModel.cellSourceChanged = false;
}
this._layoutEmitter.fire();
}));

View File

@@ -9,7 +9,7 @@ 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 { TransformMarkdownAction, MarkdownButtonType, ToggleViewAction } from 'sql/workbench/contrib/notebook/browser/markdownToolbarActions';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { DropdownMenuActionViewItem } from 'sql/base/browser/ui/buttonMenu/buttonMenu';
@@ -38,8 +38,15 @@ export class MarkdownToolbarComponent {
public optionHeading3 = localize('optionHeading3', "Heading 3");
public optionParagraph = localize('optionParagraph', "Paragraph");
public textViewButton = localize('textViewButton', "View as Text");
public splitViewButton = localize('splitViewButton', "View as Split");
public markdownButton = localize('markdownButton', "View as Markdown");
@Input() public cellModel: ICellModel;
private _actionBar: Taskbar;
_toggleTextViewAction: ToggleViewAction;
_toggleSplitViewAction: ToggleViewAction;
_toggleMarkdownViewAction: ToggleViewAction;
constructor(
@Inject(IInstantiationService) private _instantiationService: IInstantiationService,
@@ -60,7 +67,6 @@ export class MarkdownToolbarComponent {
let listButton = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.listText', '', 'list masked-icon', this.buttonList, this.cellModel, MarkdownButtonType.UNORDERED_LIST);
let orderedListButton = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.orderedText', '', 'ordered-list masked-icon', this.buttonOrderedList, this.cellModel, MarkdownButtonType.ORDERED_LIST);
let imageButton = this._instantiationService.createInstance(TransformMarkdownAction, 'notebook.imageText', '', 'insert-image masked-icon', this.buttonImage, this.cellModel, MarkdownButtonType.IMAGE);
let togglePreviewAction = this._instantiationService.createInstance(TogglePreviewAction, 'notebook.togglePreview masked-icon', 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);
@@ -68,6 +74,10 @@ export class MarkdownToolbarComponent {
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);
this._toggleTextViewAction = this._instantiationService.createInstance(ToggleViewAction, 'notebook.toggleTextView', '', 'masked-icon show-text active', this.textViewButton, true, false);
this._toggleSplitViewAction = this._instantiationService.createInstance(ToggleViewAction, 'notebook.toggleSplitView', '', 'masked-icon split-toggle-on', this.splitViewButton, true, true);
this._toggleMarkdownViewAction = this._instantiationService.createInstance(ToggleViewAction, 'notebook.toggleMarkdownView', '', 'masked-icon show-markdown', this.markdownButton, false, true);
let taskbar = <HTMLElement>this.mdtoolbar.nativeElement;
this._actionBar = new Taskbar(taskbar);
this._actionBar.context = this;
@@ -99,7 +109,18 @@ export class MarkdownToolbarComponent {
{ action: orderedListButton },
{ action: imageButton },
{ element: buttonDropdownContainer },
{ action: togglePreviewAction }
{ action: this._toggleTextViewAction },
{ action: this._toggleSplitViewAction },
{ action: this._toggleMarkdownViewAction }
]);
}
public removeActiveClassFromModeActions() {
const activeClass = ' active';
for (let action of [this._toggleTextViewAction, this._toggleSplitViewAction, this._toggleMarkdownViewAction]) {
if (action.class.includes(activeClass)) {
action.class = action.class.replace(activeClass, '');
}
}
}
}

View File

@@ -20,65 +20,86 @@
padding: 10px 16px 4px 16px;
}
.markdown-toolbar ul {
position: relative;
}
.markdown-toolbar .carbon-taskbar li.action-item {
display: inline-block;
.markdown-toolbar .carbon-taskbar ul.actions-container li {
margin-right: 14px;
}
.markdown-toolbar .carbon-taskbar li:nth-child(1) {
.markdown-toolbar .carbon-taskbar ul.actions-container li:nth-child(1) {
margin-right: 9px;
}
.markdown-toolbar .carbon-taskbar li:nth-child(2) {
.markdown-toolbar .carbon-taskbar ul.actions-container li:nth-child(2) {
margin-right: 9px;
}
.markdown-toolbar .carbon-taskbar li.action-item .masked-pseudo-after.dropdown-arrow {
.markdown-toolbar .carbon-taskbar ul.actions-container li:nth-last-child(-n+3) {
margin-left: auto;
}
.markdown-toolbar .carbon-taskbar ul.actions-container li:nth-last-child(-n+2) {
margin-left: initial;
}
.markdown-toolbar .carbon-taskbar ul.actions-container li:nth-last-child(-n+1) {
margin-left: initial;
margin-right: 0;
}
.markdown-toolbar .carbon-taskbar ul.actions-container 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 {
.markdown-toolbar .carbon-taskbar ul.actions-container 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 {
.markdown-toolbar .carbon-taskbar a {
display: flex;
height: 20px;
width: 20px;
}
.markdown-toolbar a.active::after {
border-bottom-style: solid;
border-bottom-width: 2px;
bottom: -2px;
content: '';
display: block;
left: 50%;
position: absolute;
width: 32px;
transform: translateX(-50%);
}
.markdown-toolbar li a.codicon.masked-icon::before {
.markdown-toolbar .codicon.masked-icon::before {
-webkit-mask-size: auto;
mask-size: auto;
}
.markdown-toolbar li a.codicon.code::before {
.markdown-toolbar .codicon.code::before {
-webkit-mask-size: 88% 100%;
mask-size: 88% 100%;
}
.markdown-toolbar li a.codicon.insert-link::before {
.markdown-toolbar .codicon.insert-link::before {
-webkit-mask-size: 80% 100%;
mask-size: 80% 100%;
}
.markdown-toolbar li a.codicon.ordered-list::before {
.markdown-toolbar .codicon.ordered-list::before {
-webkit-mask-size: 86% 100%;
mask-size: 86% 100%;
}
.markdown-toolbar .codicon.show-markdown::before {
-webkit-mask-size: 100%;
mask-size: 100%;
}
.markdown-toolbar .codicon.show-text::before {
-webkit-mask-size: 100%;
mask-size: 100%;
}
text-cell-component .offscreen {
height: 1px;
margin-top: -1px;

View File

@@ -5,11 +5,11 @@
*--------------------------------------------------------------------------------------------*/
-->
<div style="width: 100%; height: 100%; display: flex; flex-flow: column" (mouseover)="hover=true" (mouseleave)="hover=false">
<markdown-toolbar-component #markdownToolbar *ngIf="previewFeaturesEnabled === true && isEditMode" [cellModel]="cellModel"></markdown-toolbar-component>
<div class="notebook-text" [class.edit-mode]="isEditMode" [class.show-preview]="showPreview">
<code-component *ngIf="isEditMode" [cellModel]="cellModel" (onContentChanged)="handleContentChanged()" [model]="model" [activeCellId]="activeCellId">
<markdown-toolbar-component #markdownToolbar *ngIf="isEditMode" [cellModel]="cellModel"></markdown-toolbar-component>
<div class="notebook-text" [class.show-markdown]="markdownMode" [class.show-preview]="previewMode">
<code-component *ngIf="markdownMode" [cellModel]="cellModel" (onContentChanged)="handleContentChanged()" [model]="model" [activeCellId]="activeCellId">
</code-component>
<div #preview link-handler *ngIf="showPreview" [isTrusted]="isTrusted" [notebookUri]="notebookUri" class="notebook-preview">
<div #preview link-handler *ngIf="previewMode" [isTrusted]="isTrusted" [notebookUri]="notebookUri" contentEditable="{{!markdownMode && previewMode && isEditMode ? 'true' : 'false'}}" class="notebook-preview" (input)="handleHtmlChanged()">
</div>
</div>
</div>

View File

@@ -27,6 +27,8 @@ import { CodeComponent } from 'sql/workbench/contrib/notebook/browser/cellViews/
import { NotebookRange, ICellEditorProvider, INotebookService } from 'sql/workbench/services/notebook/browser/notebookService';
import { IColorTheme } from 'vs/platform/theme/common/themeService';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import * as turndownPluginGfm from '../turndownPluginGfm';
import TurndownService = require('turndown');
import * as Mark from 'mark.js';
import { NotebookInput } from 'sql/workbench/contrib/notebook/browser/models/notebookInput';
@@ -73,10 +75,16 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
document.execCommand('selectAll');
}
@HostListener('document:keydown.meta.z', ['$event'])
onUndo(e) {
document.execCommand('undo');
}
private _content: string | string[];
private _lastTrustedMode: boolean;
private isEditMode: boolean;
private showPreview: boolean;
private _previewMode: boolean = true;
private _markdownMode: boolean;
private _sanitizer: ISanitizer;
private _model: NotebookModel;
private _activeCellId: string;
@@ -85,6 +93,7 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
private markdownRenderer: NotebookMarkdownRenderer;
private markdownResult: IMarkdownRenderResult;
public previewFeaturesEnabled: boolean = false;
private turndownService;
public doubleClickEditEnabled: boolean;
constructor(
@@ -96,8 +105,7 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
) {
super();
this.isEditMode = true;
this.showPreview = true;
this.setTurndownOptions();
this.markdownRenderer = this._instantiationService.createInstance(NotebookMarkdownRenderer);
this._register(toDisposable(() => {
if (this.markdownResult) {
@@ -146,6 +154,7 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
this._register(this.themeService.onDidColorThemeChange(this.updateTheme, this));
this.updateTheme(this.themeService.getColorTheme());
this.setFocusAndScroll();
this.cellModel.isEditMode = false;
this._register(this.cellModel.onOutputsChanged(e => {
this.updatePreview();
}));
@@ -153,9 +162,15 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
if (mode !== this.isEditMode) {
this.toggleEditMode(mode);
}
this._changeRef.detectChanges();
}));
this._register(this.cellModel.onCellPreviewChanged(preview => {
this._register(this.cellModel.onCellPreviewModeChanged(preview => {
this.previewMode = preview;
this.focusIfPreviewMode();
}));
this._register(this.cellModel.onCellMarkdownModeChanged(markdown => {
this.markdownMode = markdown;
this.focusIfPreviewMode();
}));
}
@@ -196,7 +211,7 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
let trustedChanged = this.cellModel && this._lastTrustedMode !== this.cellModel.trustedMode;
let cellModelSourceJoined = Array.isArray(this.cellModel.source) ? this.cellModel.source.join('') : this.cellModel.source;
let contentJoined = Array.isArray(this._content) ? this._content.join('') : this._content;
let contentChanged = contentJoined !== cellModelSourceJoined || cellModelSourceJoined.length === 0 || this.showPreview === true;
let contentChanged = contentJoined !== cellModelSourceJoined || cellModelSourceJoined.length === 0 || this._previewMode === true;
if (trustedChanged || contentChanged) {
this._lastTrustedMode = this.cellModel.trustedMode;
if ((!cellModelSourceJoined) && !this.isEditMode) {
@@ -206,9 +221,8 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
this._content = localize('addContent', "Add content here...");
}
} else {
this._content = this.cellModel.source;
this._content = this.cellModel.source[0] === '' ? '<p>&nbsp;</p>' : this.cellModel.source;
}
this.markdownRenderer.setNotebookURI(this.cellModel.notebookModel.notebookUri);
this.markdownResult = this.markdownRenderer.render({
isTrusted: true,
@@ -216,14 +230,22 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
});
this.markdownResult.element.innerHTML = this.sanitizeContent(this.markdownResult.element.innerHTML);
this.setLoading(false);
if (this.showPreview) {
if (this._previewMode) {
let outputElement = <HTMLElement>this.output.nativeElement;
outputElement.innerHTML = this.markdownResult.element.innerHTML;
this.cellModel.renderedOutputTextContent = this.getRenderedTextOutput();
outputElement.focus();
}
}
}
private updateCellSource(): void {
let textOutputElement = <HTMLElement>this.output.nativeElement;
let newCellSource: string = this.turndownService.turndown(textOutputElement.innerHTML, { gfm: true });
this.cellModel.source = newCellSource;
this._changeRef.detectChanges();
}
//Sanitizes the content based on trusted mode of Cell Model
private sanitizeContent(content: string): string {
if (this.cellModel && !this.cellModel.trustedMode) {
@@ -247,23 +269,40 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
this.updatePreview();
}
public handleHtmlChanged(): void {
this.updateCellSource();
}
public toggleEditMode(editMode?: boolean): void {
this.isEditMode = editMode !== undefined ? editMode : !this.isEditMode;
this.cellModel.isEditMode = this.isEditMode;
if (!this.isEditMode) {
this.previewMode = true;
this.markdownMode = false;
}
this.updatePreview();
this._changeRef.detectChanges();
}
public get previewMode(): boolean {
return this.showPreview;
return this._previewMode;
}
public set previewMode(value: boolean) {
this.showPreview = value;
this._changeRef.detectChanges();
this.updatePreview();
if (this._previewMode !== value) {
this._previewMode = value;
this.updatePreview();
this._changeRef.detectChanges();
}
}
public get markdownMode(): boolean {
return this._markdownMode;
}
public set markdownMode(value: boolean) {
if (this._markdownMode !== value) {
this._markdownMode = value;
this._changeRef.detectChanges();
}
}
private toggleUserSelect(userSelect: boolean): void {
@@ -281,11 +320,21 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
this.toggleEditMode(this.isActive());
if (this.output && this.output.nativeElement) {
(<HTMLElement>this.output.nativeElement).scrollTo({ behavior: 'smooth' });
let outputElement = this.output.nativeElement as HTMLElement;
outputElement.scrollTo({ behavior: 'smooth' });
}
}
protected isActive() {
private focusIfPreviewMode(): void {
if (this.previewMode && !this.markdownMode) {
let outputElement = this.output?.nativeElement as HTMLElement;
if (outputElement) {
outputElement.focus();
}
}
}
protected isActive(): boolean {
return this.cellModel && this.cellModel.id === this.activeCellId;
}
@@ -330,23 +379,25 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
}
private getHtmlElements(): any[] {
let hostElem = this.output.nativeElement;
let hostElem = this.output?.nativeElement;
let children = [];
for (let element of hostElem.children) {
if (element.nodeName.toLowerCase() === 'table') {
// add table header and table rows.
if (element.children.length > 0) {
children.push(element.children[0]);
if (element.children.length > 1) {
for (let trow of element.children[1].children) {
children.push(trow);
if (hostElem) {
for (let element of hostElem.children) {
if (element.nodeName.toLowerCase() === 'table') {
// add table header and table rows.
if (element.children.length > 0) {
children.push(element.children[0]);
if (element.children.length > 1) {
for (let trow of element.children[1].children) {
children.push(trow);
}
}
}
} else if (element.children.length > 1) {
children = children.concat(this.getChildren(element));
} else {
children.push(element);
}
} else if (element.children.length > 1) {
children = children.concat(this.getChildren(element));
} else {
children.push(element);
}
}
return children;
@@ -377,6 +428,33 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
return textOutput;
}
private setTurndownOptions() {
this.turndownService = new TurndownService({ 'emDelimiter': '_', 'bulletListMarker': '-' });
this.turndownService.keep(['u', 'mark']);
this.turndownService.use(turndownPluginGfm.gfm);
this.turndownService.addRule('pre', {
filter: 'pre',
replacement: function (content, node) {
return '\n```\n' + node.textContent + '\n```\n';
}
});
this.turndownService.addRule('span', {
filter: function (node, options) {
return (
node.nodeName === 'MARK' ||
(node.nodeName === 'SPAN' &&
node.getAttribute('style') === 'background-color: yellow;')
);
},
replacement: function (content, node) {
if (node.nodeName === 'SPAN') {
return '<mark>' + node.textContent + '</mark>';
}
return node.textContent;
}
});
}
// Enables edit mode on double clicking active cell
private enableActiveCellEditOnDoubleClick() {
if (!this.isEditMode && this.doubleClickEditEnabled) {

View File

@@ -6,41 +6,34 @@
text-cell-component {
display: block;
}
text-cell-component .notebook-text {
display: flex;
outline: none;
}
text-cell-component code-component {
flex-direction: column;
}
text-cell-component .notebook-preview {
flex-direction: column;
width: 100%;
user-select: none;
padding-left: 8px;
padding-right: 8px;
overflow: hidden;
}
text-cell-component .edit-mode code-component {
display: block;
user-select: none;
width: 100%;
outline: none;
}
text-cell-component .show-preview.edit-mode code-component {
width: 50%;
text-cell-component .show-markdown.show-preview code-component {
width: 50%
}
text-cell-component .edit-mode .notebook-preview {
text-cell-component .show-markdown .notebook-preview {
border-color: transparent;
border-left-width: 1px;
border-style: solid;
border-top-width: 0;
flex-direction: column;
width: 50%;
width: 50%
}
.notebook-preview.actionselect {
user-select: text;
}
text-cell-component code-component .monaco-scrollable-element.editor-scrollable.vs {
left: 16px!important;
}