Markdown Overhaul (#2624)
Rebuilt Markdown editor/views into new components
This commit is contained in:
parent
945510dcf0
commit
6b54279bb7
50 changed files with 819 additions and 3896 deletions
|
@ -50,4 +50,3 @@ angular.module('quay').directive('repoPanelInfo', function () {
|
|||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
|
|
@ -30,11 +30,11 @@
|
|||
<cor-tab-content>
|
||||
<!-- Description -->
|
||||
<cor-tab-pane id="description">
|
||||
<div class="description markdown-input"
|
||||
content="$ctrl.repository.description"
|
||||
can-write="$ctrl.repository.can_write"
|
||||
content-changed="$ctrl.updateDescription"
|
||||
field-title="'application description'">
|
||||
<div class="description">
|
||||
<markdown-input content="$ctrl.repository.description"
|
||||
can-write="$ctrl.repository.can_write"
|
||||
(content-changed)="$ctrl.updateDescription($event.content)"
|
||||
field-title="repository description"></markdown-input>
|
||||
</div>
|
||||
</cor-tab-pane>
|
||||
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
/**
|
||||
* An element which display an inline editor for writing and previewing markdown text.
|
||||
*/
|
||||
angular.module('quay').directive('markdownEditor', function () {
|
||||
var counter = 0;
|
||||
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/markdown-editor.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'content': '=content',
|
||||
},
|
||||
controller: function($scope, $element, $timeout) {
|
||||
$scope.id = (counter++);
|
||||
$scope.previewing = false;
|
||||
|
||||
$timeout(function() {
|
||||
var converter = Markdown.getSanitizingConverter();
|
||||
var editor = new Markdown.Editor(converter, '-' + $scope.id);
|
||||
editor.run();
|
||||
});
|
||||
|
||||
$scope.togglePreview = function() {
|
||||
$scope.previewing = !$scope.previewing;
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* An element which allows for entry of markdown content and previewing its rendering.
|
||||
*/
|
||||
angular.module('quay').directive('markdownInput', function () {
|
||||
var counter = 0;
|
||||
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/markdown-input.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'content': '=content',
|
||||
'canWrite': '=canWrite',
|
||||
'contentChanged': '=contentChanged',
|
||||
'fieldTitle': '=fieldTitle'
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
var elm = $element[0];
|
||||
|
||||
$scope.id = (counter++);
|
||||
|
||||
$scope.editContent = function() {
|
||||
if (!$scope.canWrite) { return; }
|
||||
|
||||
if (!$scope.markdownDescriptionEditor) {
|
||||
var converter = Markdown.getSanitizingConverter();
|
||||
var editor = new Markdown.Editor(converter, '-description-' + $scope.id);
|
||||
editor.run();
|
||||
$scope.markdownDescriptionEditor = editor;
|
||||
}
|
||||
|
||||
$('#wmd-input-description-' + $scope.id)[0].value = $scope.content;
|
||||
$(elm).find('.modal').modal({});
|
||||
};
|
||||
|
||||
$scope.saveContent = function() {
|
||||
$scope.content = $('#wmd-input-description-' + $scope.id)[0].value;
|
||||
$(elm).find('.modal').modal('hide');
|
||||
|
||||
if ($scope.contentChanged) {
|
||||
$scope.contentChanged($scope.content);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -1,26 +0,0 @@
|
|||
/**
|
||||
* An element which displays its content processed as markdown.
|
||||
*/
|
||||
angular.module('quay').directive('markdownView', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/markdown-view.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'content': '=content',
|
||||
'firstLineOnly': '=firstLineOnly',
|
||||
'placeholderNeeded': '=placeholderNeeded'
|
||||
},
|
||||
controller: function($scope, $element, $sce, UtilService) {
|
||||
$scope.getMarkedDown = function(content, firstLineOnly) {
|
||||
if (firstLineOnly) {
|
||||
return $sce.trustAsHtml(UtilService.getFirstMarkdownLineAsText(content, $scope.placeholderNeeded));
|
||||
}
|
||||
return $sce.trustAsHtml(UtilService.getMarkedDown(content));
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
.markdown-editor-element textarea {
|
||||
height: 300px;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.markdown-editor-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.markdown-editor-buttons button {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.markdown-editor-buttons button:last-child {
|
||||
margin: 0;
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
<div class="markdown-editor-element">
|
||||
<!-- Write/preview tabs -->
|
||||
<ul class="nav nav-tabs" style="width: 100%;">
|
||||
<li role="presentation" ng-class="$ctrl.editMode == 'write' ? 'active': ''"
|
||||
ng-click="$ctrl.changeEditMode('write')">
|
||||
<a href="#">Write</a>
|
||||
</li>
|
||||
<li role="presentation" ng-class="$ctrl.editMode == 'preview' ? 'active': ''"
|
||||
ng-click="$ctrl.changeEditMode('preview')">
|
||||
<a href="#">Preview</a>
|
||||
</li>
|
||||
<!-- Editing toolbar -->
|
||||
<li style="float: right;">
|
||||
<markdown-toolbar ng-if="$ctrl.editMode == 'write'"
|
||||
(insert-symbol)="$ctrl.insertSymbol($event)"></markdown-toolbar>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" style="padding: 10px 0 0 0;">
|
||||
<div ng-show="$ctrl.editMode == 'write'">
|
||||
<textarea id="markdown-textarea"
|
||||
placeholder="Enter {{ ::$ctrl.fieldTitle }}"
|
||||
ng-model="$ctrl.content"></textarea>
|
||||
</div>
|
||||
<div class="markdown-editor-preview"
|
||||
ng-if="$ctrl.editMode == 'preview'">
|
||||
<markdown-view content="$ctrl.content"></markdown-view>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="markdown-editor-actions">
|
||||
<div class="markdown-editor-buttons">
|
||||
<button type="button" class="btn btn-default"
|
||||
ng-click="$ctrl.discardChanges()">
|
||||
Close
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary"
|
||||
ng-click="$ctrl.saveChanges()">
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,147 @@
|
|||
import { MarkdownEditorComponent, EditMode } from './markdown-editor.component';
|
||||
import { MarkdownSymbol } from '../../../types/common.types';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("MarkdownEditorComponent", () => {
|
||||
var component: MarkdownEditorComponent;
|
||||
var textarea: Mock<ng.IAugmentedJQuery | any>;
|
||||
var documentMock: Mock<HTMLElement & Document>;
|
||||
|
||||
beforeEach(() => {
|
||||
textarea = new Mock<ng.IAugmentedJQuery | any>();
|
||||
documentMock = new Mock<HTMLElement & Document>();
|
||||
const $documentMock: any = [documentMock.Object];
|
||||
component = new MarkdownEditorComponent($documentMock, 'chrome');
|
||||
component.textarea = textarea.Object;
|
||||
});
|
||||
|
||||
describe("changeEditMode", () => {
|
||||
|
||||
it("sets component's edit mode to given mode", () => {
|
||||
const editMode: EditMode = "preview";
|
||||
component.changeEditMode(editMode);
|
||||
|
||||
expect(component.currentEditMode).toEqual(editMode);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertSymbol", () => {
|
||||
var event: {symbol: MarkdownSymbol};
|
||||
var markdownSymbols: {type: MarkdownSymbol, characters: string, shiftBy: number}[];
|
||||
var innerText: string;
|
||||
|
||||
beforeEach(() => {
|
||||
event = {symbol: 'heading1'};
|
||||
innerText = "Here is some text";
|
||||
markdownSymbols = [
|
||||
{type: 'heading1', characters: '# ', shiftBy: 2},
|
||||
{type: 'heading2', characters: '## ', shiftBy: 3},
|
||||
{type: 'heading3', characters: '### ', shiftBy: 4},
|
||||
{type: 'bold', characters: '****', shiftBy: 2},
|
||||
{type: 'italics', characters: '__', shiftBy: 1},
|
||||
{type: 'bulleted-list', characters: '- ', shiftBy: 2},
|
||||
{type: 'numbered-list', characters: '1. ', shiftBy: 3},
|
||||
{type: 'quote', characters: '> ', shiftBy: 2},
|
||||
{type: 'link', characters: '[](url)', shiftBy: 1},
|
||||
{type: 'code', characters: '``', shiftBy: 1},
|
||||
];
|
||||
|
||||
textarea.setup(mock => mock.focus);
|
||||
textarea.setup(mock => mock.substr).is((start, end) => '');
|
||||
textarea.setup(mock => mock.val).is((value?) => innerText);
|
||||
textarea.setup(mock => mock.prop).is((prop) => {
|
||||
switch (prop) {
|
||||
case "selectionStart":
|
||||
return 0;
|
||||
case "selectionEnd":
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
documentMock.setup(mock => mock.execCommand).is((commandID, showUI, value) => false);
|
||||
});
|
||||
|
||||
it("focuses on markdown textarea", () => {
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect(<Spy>textarea.Object.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts correct characters for given symbol at cursor position", () => {
|
||||
markdownSymbols.forEach((symbol) => {
|
||||
event.symbol = symbol.type;
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(symbol.characters);
|
||||
|
||||
(<Spy>documentMock.Object.execCommand).calls.reset();
|
||||
});
|
||||
});
|
||||
|
||||
it("splices highlighted selection between inserted characters instead of deleting them", () => {
|
||||
markdownSymbols.slice(0, 1).forEach((symbol) => {
|
||||
textarea.setup(mock => mock.prop).is((prop) => {
|
||||
switch (prop) {
|
||||
case "selectionStart":
|
||||
return 0;
|
||||
case "selectionEnd":
|
||||
return innerText.length;
|
||||
}
|
||||
});
|
||||
event.symbol = symbol.type;
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[0]).toEqual('insertText');
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[1]).toBe(false);
|
||||
expect((<Spy>documentMock.Object.execCommand).calls.argsFor(0)[2]).toEqual(`${symbol.characters.slice(0, symbol.shiftBy)}${innerText}${symbol.characters.slice(symbol.shiftBy, symbol.characters.length)}`);
|
||||
|
||||
(<Spy>documentMock.Object.execCommand).calls.reset();
|
||||
});
|
||||
});
|
||||
|
||||
it("moves cursor to correct position for given symbol", () => {
|
||||
markdownSymbols.forEach((symbol) => {
|
||||
event.symbol = symbol.type;
|
||||
component.insertSymbol(event);
|
||||
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[0]).toEqual('selectionStart');
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(2)[1]).toEqual(symbol.shiftBy);
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[0]).toEqual('selectionEnd');
|
||||
expect((<Spy>textarea.Object.prop).calls.argsFor(3)[1]).toEqual(symbol.shiftBy);
|
||||
|
||||
(<Spy>textarea.Object.prop).calls.reset();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveChanges", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
component.content = "# Some markdown content";
|
||||
});
|
||||
|
||||
it("emits output event with changed content", (done) => {
|
||||
component.save.subscribe((event: {editedContent: string}) => {
|
||||
expect(event.editedContent).toEqual(component.content);
|
||||
done();
|
||||
});
|
||||
|
||||
component.saveChanges();
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardChanges", () => {
|
||||
|
||||
it("emits output event with no content", (done) => {
|
||||
component.discard.subscribe((event: {}) => {
|
||||
expect(event).toEqual({});
|
||||
done();
|
||||
});
|
||||
|
||||
component.discardChanges();
|
||||
});
|
||||
});
|
||||
});
|
133
static/js/directives/ui/markdown/markdown-editor.component.ts
Normal file
133
static/js/directives/ui/markdown/markdown-editor.component.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { Component, Inject, Input, Output, EventEmitter, ViewChild } from 'ng-metadata/core';
|
||||
import { MarkdownSymbol } from '../../../types/common.types';
|
||||
import { BrowserPlatform } from '../../../constants/platform.constant';
|
||||
import './markdown-editor.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* An editing interface for Markdown content.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-editor',
|
||||
templateUrl: '/static/js/directives/ui/markdown/markdown-editor.component.html'
|
||||
})
|
||||
export class MarkdownEditorComponent {
|
||||
|
||||
@Input('<') public content: string;
|
||||
@Output() public save: EventEmitter<{editedContent: string}> = new EventEmitter();
|
||||
@Output() public discard: EventEmitter<any> = new EventEmitter();
|
||||
// Textarea is public for testability, should not be directly accessed
|
||||
@ViewChild('#markdown-textarea') public textarea: ng.IAugmentedJQuery;
|
||||
private editMode: EditMode = "write";
|
||||
|
||||
constructor(@Inject('$document') private $document: ng.IDocumentService,
|
||||
@Inject('BrowserPlatform') private browserPlatform: BrowserPlatform) {
|
||||
|
||||
}
|
||||
|
||||
public changeEditMode(newMode: EditMode): void {
|
||||
this.editMode = newMode;
|
||||
}
|
||||
|
||||
public insertSymbol(event: {symbol: MarkdownSymbol}): void {
|
||||
this.textarea.focus();
|
||||
|
||||
const startPos: number = this.textarea.prop('selectionStart');
|
||||
const endPos: number = this.textarea.prop('selectionEnd');
|
||||
const innerText: string = this.textarea.val().slice(startPos, endPos);
|
||||
var shiftBy: number = 0;
|
||||
var characters: string = '';
|
||||
|
||||
switch (event.symbol) {
|
||||
case 'heading1':
|
||||
characters = '# ';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'heading2':
|
||||
characters = '## ';
|
||||
shiftBy = 3;
|
||||
break;
|
||||
case 'heading3':
|
||||
characters = '### ';
|
||||
shiftBy = 4;
|
||||
break;
|
||||
case 'bold':
|
||||
characters = '****';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'italics':
|
||||
characters = '__';
|
||||
shiftBy = 1;
|
||||
break;
|
||||
case 'bulleted-list':
|
||||
characters = '- ';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'numbered-list':
|
||||
characters = '1. ';
|
||||
shiftBy = 3;
|
||||
break;
|
||||
case 'quote':
|
||||
characters = '> ';
|
||||
shiftBy = 2;
|
||||
break;
|
||||
case 'link':
|
||||
characters = '[](url)';
|
||||
shiftBy = 1;
|
||||
break;
|
||||
case 'code':
|
||||
characters = '``';
|
||||
shiftBy = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
const cursorPos: number = startPos + shiftBy;
|
||||
|
||||
if (startPos != endPos) {
|
||||
this.insertText(`${characters.slice(0, shiftBy)}${innerText}${characters.slice(shiftBy, characters.length)}`,
|
||||
startPos,
|
||||
endPos);
|
||||
}
|
||||
else {
|
||||
this.insertText(characters, startPos, endPos);
|
||||
}
|
||||
|
||||
this.textarea.prop('selectionStart', cursorPos);
|
||||
this.textarea.prop('selectionEnd', cursorPos);
|
||||
}
|
||||
|
||||
public saveChanges(): void {
|
||||
this.save.emit({editedContent: this.content});
|
||||
}
|
||||
|
||||
public discardChanges(): void {
|
||||
this.discard.emit({});
|
||||
}
|
||||
|
||||
public get currentEditMode(): EditMode {
|
||||
return this.editMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text in such a way that the browser adds it to the 'undo' stack. This has different feature support
|
||||
* depending on the platform.
|
||||
*/
|
||||
private insertText(text: string, startPos: number, endPos: number): void {
|
||||
if (this.browserPlatform === 'firefox') {
|
||||
// FIXME: Ctrl-Z highlights previous text
|
||||
this.textarea.val(this.textarea.val().substr(0, startPos) +
|
||||
text +
|
||||
this.textarea.val().substr(endPos, this.textarea.val().length));
|
||||
}
|
||||
else {
|
||||
// TODO: Test other platforms (IE...)
|
||||
this.$document[0].execCommand('insertText', false, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Type representing the current editing mode.
|
||||
*/
|
||||
export type EditMode = "write" | "preview";
|
|
@ -0,0 +1,14 @@
|
|||
.markdown-input-container .glyphicon-edit {
|
||||
float: right;
|
||||
color: #ddd;
|
||||
transition: color 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.markdown-input-container .glyphicon-edit:hover {
|
||||
cursor: pointer;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.markdown-input-placeholder-editable:hover {
|
||||
cursor: pointer;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
<div class="markdown-input-container">
|
||||
<div>
|
||||
<span class="glyphicon glyphicon-edit"
|
||||
ng-if="$ctrl.canWrite && !$ctrl.isEditing"
|
||||
ng-click="$ctrl.editContent()"
|
||||
data-title="Edit {{ ::$ctrl.fieldTitle }}" data-placement="left" bs-tooltip></span>
|
||||
<div ng-if="$ctrl.content">
|
||||
<markdown-view content="$ctrl.content"></markdown-view>
|
||||
</div>
|
||||
<!-- Not set and can write -->
|
||||
<span class="markdown-input-placeholder-editable"
|
||||
ng-if="!$ctrl.content && $ctrl.canWrite"
|
||||
ng-click="$ctrl.editContent()">
|
||||
<i>Click to set {{ ::$ctrl.fieldTitle }}</i>
|
||||
</span>
|
||||
<!-- Not set and cannot write -->
|
||||
<span class="markdown-input-placeholder"
|
||||
ng-if="!$ctrl.content && !$ctrl.canWrite">
|
||||
<i>No {{ ::$ctrl.fieldTitle }} has been set</i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Inline editor -->
|
||||
<div ng-if="$ctrl.isEditing" style="margin-top: 20px;">
|
||||
<markdown-editor content="$ctrl.content"
|
||||
(save)="$ctrl.saveContent($event)"
|
||||
(discard)="$ctrl.discardContent($event)"></markdown-editor>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,34 @@
|
|||
import { MarkdownInputComponent } from './markdown-input.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("MarkdownInputComponent", () => {
|
||||
var component: MarkdownInputComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new MarkdownInputComponent();
|
||||
});
|
||||
|
||||
describe("editContent", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("saveContent", () => {
|
||||
var editedContent: string;
|
||||
|
||||
it("emits output event with changed content", (done) => {
|
||||
editedContent = "# Some markdown here";
|
||||
component.contentChanged.subscribe((event: {content: string}) => {
|
||||
expect(event.content).toEqual(editedContent);
|
||||
done();
|
||||
});
|
||||
|
||||
component.saveContent({editedContent: editedContent});
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardContent", () => {
|
||||
|
||||
});
|
||||
});
|
32
static/js/directives/ui/markdown/markdown-input.component.ts
Normal file
32
static/js/directives/ui/markdown/markdown-input.component.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
|
||||
import './markdown-input.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* Displays editable Markdown content.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-input',
|
||||
templateUrl: '/static/js/directives/ui/markdown/markdown-input.component.html'
|
||||
})
|
||||
export class MarkdownInputComponent {
|
||||
|
||||
@Input('<') public content: string;
|
||||
@Input('<') public canWrite: boolean;
|
||||
@Input('@') public fieldTitle: string;
|
||||
@Output() public contentChanged: EventEmitter<{content: string}> = new EventEmitter();
|
||||
private isEditing: boolean = false;
|
||||
|
||||
public editContent(): void {
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
public saveContent(event: {editedContent: string}): void {
|
||||
this.contentChanged.emit({content: event.editedContent});
|
||||
this.isEditing = false;
|
||||
}
|
||||
|
||||
public discardContent(event: any): void {
|
||||
this.isEditing = false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.markdown-toolbar-element .dropdown-menu li > * {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.markdown-toolbar-element .dropdown-menu li:hover {
|
||||
cursor: pointer;
|
||||
background-color: #e6e6e6;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
<div class="markdown-toolbar-element">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
data-title="Add header" data-container="body" bs-tooltip>
|
||||
<span class="glyphicon glyphicon-text-size"></span>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading1'})"><h2>Heading</h2></li>
|
||||
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading2'})"><h3>Heading</h3></li>
|
||||
<li ng-click="$ctrl.insertSymbol.emit({symbol: 'heading3'})"><h4>Heading</h4></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Bold" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'bold'})">
|
||||
<span class="glyphicon glyphicon-bold"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Italics" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'italics'})">
|
||||
<span class="glyphicon glyphicon-italic"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Block quote" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'quote'})">
|
||||
<i class="fa fa-quote-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Code snippet" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'code'})">
|
||||
<span class="glyphicon glyphicon-menu-left" style="margin-right: -6px;"></span>
|
||||
<span class="glyphicon glyphicon-menu-right"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="URL" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'link'})">
|
||||
<span class="glyphicon glyphicon-link"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Bulleted list" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'bulleted-list'})">
|
||||
<span class="glyphicon glyphicon-list"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm"
|
||||
data-title="Numbered list" data-container="body" data-container="body" bs-tooltip
|
||||
ng-click="$ctrl.insertSymbol.emit({symbol: 'numbered-list'})">
|
||||
<i class="fa fa-list-ol" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,11 @@
|
|||
import { MarkdownToolbarComponent } from './markdown-toolbar.component';
|
||||
import { MarkdownSymbol } from '../../../types/common.types';
|
||||
|
||||
|
||||
describe("MarkdownToolbarComponent", () => {
|
||||
var component: MarkdownToolbarComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new MarkdownToolbarComponent();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
import { Component, Input, Output, EventEmitter } from 'ng-metadata/core';
|
||||
import { MarkdownSymbol } from '../../../types/common.types';
|
||||
import './markdown-toolbar.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* Toolbar containing Markdown symbol shortcuts.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-toolbar',
|
||||
templateUrl: '/static/js/directives/ui/markdown/markdown-toolbar.component.html'
|
||||
})
|
||||
export class MarkdownToolbarComponent {
|
||||
|
||||
@Input('<') public allowUndo: boolean = true;
|
||||
@Output() public insertSymbol: EventEmitter<{symbol: MarkdownSymbol}> = new EventEmitter();
|
||||
}
|
11
static/js/directives/ui/markdown/markdown-view.component.css
Normal file
11
static/js/directives/ui/markdown/markdown-view.component.css
Normal file
|
@ -0,0 +1,11 @@
|
|||
.markdown-view-content {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-view-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code * {
|
||||
font-family: "Lucida Console", Monaco, monospace;
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
<div class="markdown-view-content"
|
||||
ng-bind-html="$ctrl.convertedHTML"></div>
|
|
@ -0,0 +1,81 @@
|
|||
import { MarkdownViewComponent } from './markdown-view.component';
|
||||
import { SimpleChanges } from 'ng-metadata/core';
|
||||
import { Converter, ConverterOptions } from 'showdown';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("MarkdownViewComponent", () => {
|
||||
var component: MarkdownViewComponent;
|
||||
var markdownConverterMock: Mock<Converter>;
|
||||
var $sceMock: Mock<ng.ISCEService>;
|
||||
var $sanitizeMock: ng.sanitize.ISanitizeService;
|
||||
|
||||
beforeEach(() => {
|
||||
markdownConverterMock = new Mock<Converter>();
|
||||
$sceMock = new Mock<ng.ISCEService>();
|
||||
$sanitizeMock = jasmine.createSpy('$sanitizeSpy').and.callFake((html: string) => html);
|
||||
component = new MarkdownViewComponent((options: ConverterOptions) => markdownConverterMock.Object,
|
||||
$sceMock.Object,
|
||||
$sanitizeMock);
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
var changes: SimpleChanges;
|
||||
var markdown: string;
|
||||
var expectedPlaceholder: string;
|
||||
var markdownChars: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
changes = {};
|
||||
markdown = `## Heading\n Code line\n\n- Item\n> Quote\`code snippet\`\n\nThis is my project!`;
|
||||
expectedPlaceholder = `<p style="visibility:hidden">placeholder</p>`;
|
||||
markdownChars = ['#', '-', '>', '`'];
|
||||
markdownConverterMock.setup(mock => mock.makeHtml).is((text) => text);
|
||||
$sceMock.setup(mock => mock.trustAsHtml).is((html) => html);
|
||||
});
|
||||
|
||||
it("calls markdown converter to convert content to HTML when content is changed", () => {
|
||||
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
|
||||
});
|
||||
|
||||
it("only converts first line of content to HTML if flag is set when content is changed", () => {
|
||||
component.firstLineOnly = true;
|
||||
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
const expectedHtml: string = markdown.split('\n')
|
||||
.filter(line => line.indexOf(' ') != 0)
|
||||
.filter(line => line.trim().length != 0)
|
||||
.filter(line => markdownChars.indexOf(line.trim()[0]) == -1)[0];
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(expectedHtml);
|
||||
});
|
||||
|
||||
it("sets converted HTML to be a placeholder if flag is set and content is empty", () => {
|
||||
component.placeholderNeeded = true;
|
||||
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml)).not.toHaveBeenCalled();
|
||||
expect((<Spy>$sceMock.Object.trustAsHtml).calls.argsFor(0)[0]).toEqual(expectedPlaceholder);
|
||||
});
|
||||
|
||||
it("sets converted HTML to empty string if placeholder flag is false and content is empty", () => {
|
||||
changes['content'] = {currentValue: '', previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>markdownConverterMock.Object.makeHtml).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
|
||||
});
|
||||
|
||||
it("calls $sanitize service to sanitize changed HTML content", () => {
|
||||
changes['content'] = {currentValue: markdown, previousValue: '', isFirstChange: () => false};
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>$sanitizeMock).calls.argsFor(0)[0]).toEqual(changes['content'].currentValue);
|
||||
});
|
||||
});
|
||||
});
|
51
static/js/directives/ui/markdown/markdown-view.component.ts
Normal file
51
static/js/directives/ui/markdown/markdown-view.component.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { Component, Input, Inject, OnChanges, SimpleChanges } from 'ng-metadata/core';
|
||||
import { Converter, ConverterOptions } from 'showdown';
|
||||
import 'showdown-highlightjs-extension';
|
||||
import 'highlightjs/styles/vs.css';
|
||||
import './markdown-view.component.css';
|
||||
|
||||
|
||||
/**
|
||||
* Renders Markdown content to HTML.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'markdown-view',
|
||||
templateUrl: '/static/js/directives/ui/markdown/markdown-view.component.html'
|
||||
})
|
||||
export class MarkdownViewComponent implements OnChanges {
|
||||
|
||||
@Input('<') public content: string;
|
||||
@Input('<') public firstLineOnly: boolean = false;
|
||||
@Input('<') public placeholderNeeded: boolean = false;
|
||||
private convertedHTML: string = '';
|
||||
private readonly placeholder: string = `<p style="visibility:hidden">placeholder</p>`;
|
||||
private readonly markdownChars: string[] = ['#', '-', '>', '`'];
|
||||
private markdownConverter: Converter;
|
||||
|
||||
constructor(@Inject('markdownConverterFactory') private makeConverter: (options?: ConverterOptions) => Converter,
|
||||
@Inject('$sce') private $sce: ng.ISCEService,
|
||||
@Inject('$sanitize') private $sanitize: ng.sanitize.ISanitizeService) {
|
||||
this.markdownConverter = makeConverter({extensions: ['highlightjs']});
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['content']) {
|
||||
if (!changes['content'].currentValue && this.placeholderNeeded) {
|
||||
this.convertedHTML = this.$sce.trustAsHtml(this.placeholder);
|
||||
} else if (this.firstLineOnly) {
|
||||
const firstLine: string = changes['content'].currentValue.split('\n')
|
||||
// Skip code lines
|
||||
.filter(line => line.indexOf(' ') != 0)
|
||||
// Skip empty lines
|
||||
.filter(line => line.trim().length != 0)
|
||||
// Skip control lines
|
||||
.filter(line => this.markdownChars.indexOf(line.trim()[0]) == -1)[0];
|
||||
|
||||
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(firstLine));
|
||||
} else {
|
||||
this.convertedHTML = this.$sanitize(this.markdownConverter.makeHtml(changes['content'].currentValue));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -39,8 +39,11 @@
|
|||
<span class="avatar" data="result.namespace.avatar" size="16"></span>
|
||||
<span class="result-name">{{ result.namespace.name }}/{{ result.name }}</span>
|
||||
<div class="result-description" ng-if="result.description">
|
||||
<div class="description markdown-view" content="result.description"
|
||||
first-line-only="true" placeholder-needed="false"></div>
|
||||
<div class="description">
|
||||
<markdown-view content="result.description"
|
||||
first-line-only="true"
|
||||
placeholder-needed="false"></markdown-view>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
|
|
Reference in a new issue