Merge pull request #84 from ssddanbrown/markdown_editor

Initial implementation of a markdown editor. Closes #57.
This commit is contained in:
Dan Brown 2016-03-29 20:18:11 +01:00
commit e7d8a041a8
21 changed files with 391 additions and 35 deletions

View File

@ -164,6 +164,7 @@ class PageController extends Controller
$draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id);
$page->name = $draft->name;
$page->html = $draft->html;
$page->markdown = $draft->markdown;
$page->isDraft = true;
$warnings [] = $this->pageRepo->getUserPageDraftMessage($draft);
}
@ -204,9 +205,9 @@ class PageController extends Controller
$page = $this->pageRepo->getById($pageId, true);
$this->checkOwnablePermission('page-update', $page);
if ($page->draft) {
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html']));
$draft = $this->pageRepo->updateDraftPage($page, $request->only(['name', 'html', 'markdown']));
} else {
$draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html']));
$draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html', 'markdown']));
}
$updateTime = $draft->updated_at->format('H:i');
return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]);

View File

@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model;
class Page extends Entity
{
protected $fillable = ['name', 'html', 'priority'];
protected $fillable = ['name', 'html', 'priority', 'markdown'];
protected $simpleAttributes = ['name', 'id', 'slug'];

View File

@ -4,7 +4,7 @@ use Illuminate\Database\Eloquent\Model;
class PageRevision extends Model
{
protected $fillable = ['name', 'html', 'text'];
protected $fillable = ['name', 'html', 'text', 'markdown'];
/**
* Get the user that created the page revision

View File

@ -312,6 +312,7 @@ class PageRepo extends EntityRepo
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = strip_tags($page->html);
if (setting('app-editor') !== 'markdown') $page->markdown = '';
$page->updated_by = $userId;
$page->save();
@ -348,6 +349,7 @@ class PageRepo extends EntityRepo
public function saveRevision(Page $page)
{
$revision = $this->pageRevision->fill($page->toArray());
if (setting('app-editor') !== 'markdown') $revision->markdown = '';
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
@ -386,6 +388,8 @@ class PageRepo extends EntityRepo
}
$draft->fill($data);
if (setting('app-editor') !== 'markdown') $draft->markdown = '';
$draft->save();
return $draft;
}

View File

@ -44,28 +44,39 @@ class SettingService
/**
* Gets a setting value from the cache or database.
* Looks at the system defaults if not cached or in database.
* @param $key
* @param $default
* @return mixed
*/
protected function getValueFromStore($key, $default)
{
// Check for an overriding value
$overrideValue = $this->getOverrideValue($key);
if ($overrideValue !== null) return $overrideValue;
// Check the cache
$cacheKey = $this->cachePrefix . $key;
if ($this->cache->has($cacheKey)) {
return $this->cache->get($cacheKey);
}
// Check the database
$settingObject = $this->getSettingObjectByKey($key);
if ($settingObject !== null) {
$value = $settingObject->value;
$this->cache->forever($cacheKey, $value);
return $value;
}
// Check the defaults set in the app config.
$configPrefix = 'setting-defaults.' . $key;
if (config()->has($configPrefix)) {
$value = config($configPrefix);
$this->cache->forever($cacheKey, $value);
return $value;
}
return $default;
}

View File

@ -5,6 +5,8 @@ return [
'env' => env('APP_ENV', 'production'),
'editor' => env('APP_EDITOR', 'html'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode

View File

@ -0,0 +1,10 @@
<?php
/**
* The defaults for the system settings that are saved in the database.
*/
return [
'app-editor' => 'wysiwyg'
];

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddMarkdownSupport extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('pages', function (Blueprint $table) {
$table->longText('markdown')->default('');
});
Schema::table('page_revisions', function (Blueprint $table) {
$table->longText('markdown')->default('');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('markdown');
});
Schema::table('page_revisions', function (Blueprint $table) {
$table->dropColumn('markdown');
});
}
}

View File

@ -12,6 +12,7 @@
"bootstrap-sass": "^3.0.0",
"dropzone": "^4.0.1",
"laravel-elixir": "^3.4.0",
"marked": "^0.3.5",
"zeroclipboard": "^2.2.0"
}
}

Binary file not shown.

Binary file not shown.

View File

@ -45,3 +45,4 @@ These are the great projects used to help build BookStack:
* [Dropzone.js](http://www.dropzonejs.com/)
* [ZeroClipboard](http://zeroclipboard.org/)
* [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
* [Marked](https://github.com/chjj/marked)

View File

@ -216,16 +216,20 @@ module.exports = function (ngApp, events) {
}]);
ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', function ($scope, $http, $attrs, $interval, $timeout) {
ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce',
function ($scope, $http, $attrs, $interval, $timeout, $sce) {
$scope.editorOptions = require('./pages/page-form');
$scope.editorHtml = '';
$scope.editContent = '';
$scope.draftText = '';
var pageId = Number($attrs.pageId);
var isEdit = pageId !== 0;
var autosaveFrequency = 30; // AutoSave interval in seconds.
var isMarkdown = $attrs.editorType === 'markdown';
$scope.isUpdateDraft = Number($attrs.pageUpdateDraft) === 1;
$scope.isNewPageDraft = Number($attrs.pageNewDraft) === 1;
// Set inital header draft text
if ($scope.isUpdateDraft || $scope.isNewPageDraft) {
$scope.draftText = 'Editing Draft'
} else {
@ -245,7 +249,18 @@ module.exports = function (ngApp, events) {
}, 1000);
}
$scope.editorChange = function () {}
// Actions specifically for the markdown editor
if (isMarkdown) {
$scope.displayContent = '';
// Editor change event
$scope.editorChange = function (content) {
$scope.displayContent = $sce.trustAsHtml(content);
}
}
if (!isMarkdown) {
$scope.editorChange = function() {};
}
/**
* Start the AutoSave loop, Checks for content change
@ -253,17 +268,18 @@ module.exports = function (ngApp, events) {
*/
function startAutoSave() {
currentContent.title = $('#name').val();
currentContent.html = $scope.editorHtml;
currentContent.html = $scope.editContent;
autoSave = $interval(() => {
var newTitle = $('#name').val();
var newHtml = $scope.editorHtml;
var newHtml = $scope.editContent;
if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
currentContent.html = newHtml;
currentContent.title = newTitle;
saveDraft(newTitle, newHtml);
saveDraft();
}
}, 1000 * autosaveFrequency);
}
@ -272,20 +288,22 @@ module.exports = function (ngApp, events) {
* @param title
* @param html
*/
function saveDraft(title, html) {
$http.put('/ajax/page/' + pageId + '/save-draft', {
name: title,
html: html
}).then((responseData) => {
function saveDraft() {
var data = {
name: $('#name').val(),
html: isMarkdown ? $sce.getTrustedHtml($scope.displayContent) : $scope.editContent
};
if (isMarkdown) data.markdown = $scope.editContent;
$http.put('/ajax/page/' + pageId + '/save-draft', data).then((responseData) => {
$scope.draftText = responseData.data.message;
if (!$scope.isNewPageDraft) $scope.isUpdateDraft = true;
});
}
$scope.forceDraftSave = function() {
var newTitle = $('#name').val();
var newHtml = $scope.editorHtml;
saveDraft(newTitle, newHtml);
saveDraft();
};
/**
@ -298,6 +316,7 @@ module.exports = function (ngApp, events) {
$scope.draftText = 'Editing Page';
$scope.isUpdateDraft = false;
$scope.$broadcast('html-update', responseData.data.html);
$scope.$broadcast('markdown-update', responseData.data.markdown || responseData.data.html);
$('#name').val(responseData.data.name);
$timeout(() => {
startAutoSave();

View File

@ -1,5 +1,6 @@
"use strict";
var DropZone = require('dropzone');
var markdown = require('marked');
var toggleSwitchTemplate = require('./components/toggle-switch.html');
var imagePickerTemplate = require('./components/image-picker.html');
@ -200,7 +201,82 @@ module.exports = function (ngApp, events) {
tinymce.init(scope.tinymce);
}
}
}]);
ngApp.directive('markdownInput', ['$timeout', function($timeout) {
return {
restrict: 'A',
scope: {
mdModel: '=',
mdChange: '='
},
link: function (scope, element, attrs) {
// Set initial model content
var content = element.val();
scope.mdModel = content;
scope.mdChange(markdown(content));
element.on('change input', (e) => {
content = element.val();
$timeout(() => {
scope.mdModel = content;
scope.mdChange(markdown(content));
});
});
scope.$on('markdown-update', (event, value) => {
element.val(value);
scope.mdModel= value;
scope.mdChange(markdown(value));
});
}
}
}]);
ngApp.directive('markdownEditor', ['$timeout', function($timeout) {
return {
restrict: 'A',
link: function (scope, element, attrs) {
// Elements
var input = element.find('textarea[markdown-input]');
var insertImage = element.find('button[data-action="insertImage"]');
var currentCaretPos = 0;
input.blur((event) => {
currentCaretPos = input[0].selectionStart;
});
// Insert image shortcut
input.keydown((event) => {
if (event.which === 73 && event.ctrlKey && event.shiftKey) {
event.preventDefault();
var caretPos = input[0].selectionStart;
var currentContent = input.val();
var mdImageText = "![](http://)";
input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
input.focus();
input[0].selectionStart = caretPos + ("![](".length);
input[0].selectionEnd = caretPos + ('![](http://'.length);
}
});
// Insert image from image manager
insertImage.click((event) => {
window.ImageManager.showExternal((image) => {
var caretPos = currentCaretPos;
var currentContent = input.val();
var mdImageText = "![" + image.name + "](" + image.url + ")";
input.val(currentContent.substring(0, caretPos) + mdImageText + currentContent.substring(caretPos));
input.change();
});
});
}
}
}])
};

View File

@ -93,4 +93,15 @@
url('/fonts/roboto-regular-webfont.svg#robotoregular') format('svg');
font-weight: normal;
font-style: normal;
}
/* roboto-mono-regular - latin */
// https://google-webfonts-helper.herokuapp.com
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
src: local('Roboto Mono'), local('RobotoMono-Regular'),
url('/fonts/roboto-mono-v4-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
url('/fonts/roboto-mono-v4-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}

View File

@ -26,6 +26,59 @@
display: none;
}
#markdown-editor {
position: relative;
z-index: 5;
textarea {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
padding: $-xs $-m;
color: #444;
border-radius: 0;
max-height: 100%;
flex: 1;
border: 0;
width: 100%;
&:focus {
outline: 0;
}
}
.markdown-display, .markdown-editor-wrap {
flex: 1;
position: relative;
}
.markdown-editor-wrap {
display: flex;
flex-direction: column;
border: 1px solid #DDD;
}
.markdown-display {
padding: 0 $-m 0;
margin-left: -1px;
overflow-y: scroll;
.page-content {
margin: 0 auto;
}
}
}
.editor-toolbar {
width: 100%;
padding: $-xs $-m;
font-family: 'Roboto Mono';
font-size: 11px;
line-height: 1.6;
border-bottom: 1px solid #DDD;
background-color: #EEE;
flex: none;
&:after {
content: '';
display: block;
clear: both;
}
}
label {
display: block;
line-height: 1.4em;
@ -160,6 +213,10 @@ input:checked + .toggle-switch {
width: 100%;
}
div[editor-type="markdown"] .title-input.page-title input[type="text"] {
max-width: 100%;
}
.search-box {
max-width: 100%;
position: relative;

View File

@ -157,6 +157,12 @@ span.code {
@extend .code-base;
padding: 1px $-xs;
}
pre code {
background-color: transparent;
border: 0;
font-size: 1em;
}
/*
* Text colors
*/

View File

@ -1,5 +1,5 @@
<div class="page-editor flex-fill flex" ng-controller="PageEditController" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
<div class="page-editor flex-fill flex" ng-controller="PageEditController" editor-type="{{ setting('app-editor') }}" page-id="{{ $model->id or 0 }}" page-new-draft="{{ $model->draft or 0 }}" page-update-draft="{{ $model->isDraft or 0 }}">
{{ csrf_field() }}
<div class="faded-small toolbar">
@ -42,10 +42,45 @@
</div>
</div>
<div class="edit-area flex-fill flex">
<textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editorHtml" name="html" rows="5"
@if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea>
@if($errors->has('html'))
<div class="text-neg text-small">{{ $errors->first('html') }}</div>
@if(setting('app-editor') === 'wysiwyg')
<textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editContent" name="html" rows="5"
@if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea>
@if($errors->has('html'))
<div class="text-neg text-small">{{ $errors->first('html') }}</div>
@endif
@endif
@if(setting('app-editor') === 'markdown')
<div id="markdown-editor" markdown-editor class="flex-fill flex">
<div class="markdown-editor-wrap">
<div class="editor-toolbar">
<span class="float left">Editor</span>
<div class="float right buttons">
<button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
</div>
</div>
<textarea markdown-input md-change="editorChange" md-model="editContent" name="markdown" rows="5"
@if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
</div>
<div class="markdown-editor-wrap">
<div class="editor-toolbar">
<div class="">Preview</div>
</div>
<div class="markdown-display">
<div class="page-content" ng-bind-html="displayContent"></div>
</div>
</div>
</div>
<input type="hidden" name="html" ng-value="displayContent">
@if($errors->has('markdown'))
<div class="text-neg text-small">{{ $errors->first('markdown') }}</div>
@endif
@endif
</div>
</div>

View File

@ -17,29 +17,37 @@
<div class="col-md-6">
<div class="form-group">
<label for="setting-app-name">Application name</label>
<input type="text" value="{{ Setting::get('app-name', 'BookStack') }}" name="setting-app-name" id="setting-app-name">
<input type="text" value="{{ setting('app-name', 'BookStack') }}" name="setting-app-name" id="setting-app-name">
</div>
<div class="form-group">
<label>Allow public viewing?</label>
<toggle-switch name="setting-app-public" value="{{ Setting::get('app-public') }}"></toggle-switch>
<toggle-switch name="setting-app-public" value="{{ setting('app-public') }}"></toggle-switch>
</div>
<div class="form-group">
<label>Enable higher security image uploads?</label>
<p class="small">For performance reasons, all images are public by default, This option adds a random, hard-to-guess characters in front of image names. Ensure directory indexes are not enabled to prevent easy access.</p>
<toggle-switch name="setting-app-secure-images" value="{{ Setting::get('app-secure-images') }}"></toggle-switch>
<toggle-switch name="setting-app-secure-images" value="{{ setting('app-secure-images') }}"></toggle-switch>
</div>
<div class="form-group">
<label for="setting-app-editor">Page Editor</label>
<p class="small">Select which editor will be used by all users to edit pages.</p>
<select name="setting-app-editor" id="setting-app-editor">
<option @if(setting('app-editor') === 'wysiwyg') selected @endif value="wysiwyg">WYSIWYG</option>
<option @if(setting('app-editor') === 'markdown') selected @endif value="markdown">Markdown</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group" id="logo-control">
<label for="setting-app-logo">Application Logo</label>
<p class="small">This image should be 43px in height. <br>Large images will be scaled down.</p>
<image-picker resize-height="43" show-remove="true" resize-width="200" current-image="{{ Setting::get('app-logo', '') }}" default-image="/logo.png" name="setting-app-logo" image-class="logo-image"></image-picker>
<image-picker resize-height="43" show-remove="true" resize-width="200" current-image="{{ setting('app-logo', '') }}" default-image="/logo.png" name="setting-app-logo" image-class="logo-image"></image-picker>
</div>
<div class="form-group" id="color-control">
<label for="setting-app-color">Application Primary Color</label>
<p class="small">This should be a hex value. <br> Leave empty to reset to the default color.</p>
<input type="text" value="{{ Setting::get('app-color', '') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1">
<input type="hidden" value="{{ Setting::get('app-color-light', '') }}" name="setting-app-color-light" id="setting-app-color-light" placeholder="rgba(21, 101, 192, 0.15)">
<input type="text" value="{{ setting('app-color', '') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1">
<input type="hidden" value="{{ setting('app-color-light', '') }}" name="setting-app-color-light" id="setting-app-color-light" placeholder="rgba(21, 101, 192, 0.15)">
</div>
</div>
</div>
@ -53,14 +61,14 @@
<div class="col-md-6">
<div class="form-group">
<label for="setting-registration-enabled">Allow registration?</label>
<toggle-switch name="setting-registration-enabled" value="{{ Setting::get('registration-enabled') }}"></toggle-switch>
<toggle-switch name="setting-registration-enabled" value="{{ setting('registration-enabled') }}"></toggle-switch>
</div>
<div class="form-group">
<label for="setting-registration-role">Default user role after registration</label>
<select id="setting-registration-role" name="setting-registration-role" @if($errors->has('setting-registration-role')) class="neg" @endif>
@foreach(\BookStack\Role::all() as $role)
<option value="{{$role->id}}"
@if(\Setting::get('registration-role', \BookStack\Role::first()->id) == $role->id) selected @endif
@if(setting('registration-role', \BookStack\Role::first()->id) == $role->id) selected @endif
>
{{ $role->display_name }}
</option>
@ -70,7 +78,7 @@
<div class="form-group">
<label for="setting-registration-confirmation">Require email confirmation?</label>
<p class="small">If domain restriction is used then email confirmation will be required and the below value will be ignored.</p>
<toggle-switch name="setting-registration-confirmation" value="{{ Setting::get('registration-confirmation') }}"></toggle-switch>
<toggle-switch name="setting-registration-confirmation" value="{{ setting('registration-confirmation') }}"></toggle-switch>
</div>
</div>
<div class="col-md-6">
@ -78,7 +86,7 @@
<label for="setting-registration-restrict">Restrict registration to domain</label>
<p class="small">Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
<br> Note that users will be able to change their email addresses after successful registration.</p>
<input type="text" id="setting-registration-restrict" name="setting-registration-restrict" placeholder="No restriction set" value="{{ Setting::get('registration-restrict', '') }}">
<input type="text" id="setting-registration-restrict" name="setting-registration-restrict" placeholder="No restriction set" value="{{ setting('registration-restrict', '') }}">
</div>
</div>
</div>

View File

@ -0,0 +1,51 @@
<?php
class MarkdownTest extends TestCase
{
protected $page;
public function setUp()
{
parent::setUp();
$this->page = \BookStack\Page::first();
}
protected function setMarkdownEditor()
{
$this->setSettings(['app-editor' => 'markdown']);
}
public function test_default_editor_is_wysiwyg()
{
$this->assertEquals(setting('app-editor'), 'wysiwyg');
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->pageHasElement('#html-editor');
}
public function test_markdown_setting_shows_markdown_editor()
{
$this->setMarkdownEditor();
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->pageNotHasElement('#html-editor')
->pageHasElement('#markdown-editor');
}
public function test_markdown_content_given_to_editor()
{
$this->setMarkdownEditor();
$mdContent = '# hello. This is a test';
$this->page->markdown = $mdContent;
$this->page->save();
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->seeInField('markdown', $mdContent);
}
public function test_html_content_given_to_editor_if_no_markdown()
{
$this->setMarkdownEditor();
$this->asAdmin()->visit($this->page->getUrl() . '/edit')
->seeInField('markdown', $this->page->html);
}
}

View File

@ -170,4 +170,28 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
$this->visit($link->link()->getUri());
return $this;
}
/**
* Check if the page contains the given element.
* @param string $selector
* @return bool
*/
protected function pageHasElement($selector)
{
$elements = $this->crawler->filter($selector);
$this->assertTrue(count($elements) > 0, "The page does not contain an element matching " . $selector);
return $this;
}
/**
* Check if the page contains the given element.
* @param string $selector
* @return bool
*/
protected function pageNotHasElement($selector)
{
$elements = $this->crawler->filter($selector);
$this->assertFalse(count($elements) > 0, "The page contains " . count($elements) . " elements matching " . $selector);
return $this;
}
}