diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index e2b10d3d3..c3d8e396c 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -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]); diff --git a/app/Page.php b/app/Page.php index 84e37d519..d2a303f61 100644 --- a/app/Page.php +++ b/app/Page.php @@ -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']; diff --git a/app/PageRevision.php b/app/PageRevision.php index f1b4bc587..c258913ff 100644 --- a/app/PageRevision.php +++ b/app/PageRevision.php @@ -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 diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 4c3512fa7..9a7502754 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -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; } diff --git a/app/Services/SettingService.php b/app/Services/SettingService.php index bcc7eae31..bf5fa918e 100644 --- a/app/Services/SettingService.php +++ b/app/Services/SettingService.php @@ -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; } diff --git a/config/app.php b/config/app.php index 650ad1d07..d305af3c0 100644 --- a/config/app.php +++ b/config/app.php @@ -5,6 +5,8 @@ return [ 'env' => env('APP_ENV', 'production'), + 'editor' => env('APP_EDITOR', 'html'), + /* |-------------------------------------------------------------------------- | Application Debug Mode diff --git a/config/setting-defaults.php b/config/setting-defaults.php new file mode 100644 index 000000000..17bae1848 --- /dev/null +++ b/config/setting-defaults.php @@ -0,0 +1,10 @@ + 'wysiwyg' + +]; \ No newline at end of file diff --git a/database/migrations/2016_03_25_123157_add_markdown_support.php b/database/migrations/2016_03_25_123157_add_markdown_support.php new file mode 100644 index 000000000..2daa32cfb --- /dev/null +++ b/database/migrations/2016_03_25_123157_add_markdown_support.php @@ -0,0 +1,39 @@ +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'); + }); + } +} diff --git a/package.json b/package.json index a1fb06b1c..7d1aa1a6a 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/fonts/roboto-mono-v4-latin-regular.woff b/public/fonts/roboto-mono-v4-latin-regular.woff new file mode 100644 index 000000000..8cb9e6fd8 Binary files /dev/null and b/public/fonts/roboto-mono-v4-latin-regular.woff differ diff --git a/public/fonts/roboto-mono-v4-latin-regular.woff2 b/public/fonts/roboto-mono-v4-latin-regular.woff2 new file mode 100644 index 000000000..1f6598111 Binary files /dev/null and b/public/fonts/roboto-mono-v4-latin-regular.woff2 differ diff --git a/readme.md b/readme.md index 62893aea3..067983e84 100644 --- a/readme.md +++ b/readme.md @@ -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) diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 29a448265..dbd2e1ae6 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -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(); diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 71b35fb42..de87950dc 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -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(); + }); + }); + + } + } }]) - }; \ No newline at end of file diff --git a/resources/assets/sass/_fonts.scss b/resources/assets/sass/_fonts.scss index 0dc8c95b2..8cf677779 100644 --- a/resources/assets/sass/_fonts.scss +++ b/resources/assets/sass/_fonts.scss @@ -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+ */ } \ No newline at end of file diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index 037dad94a..4a505c5f8 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -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; diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss index 721eeb238..1a55cf868 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -157,6 +157,12 @@ span.code { @extend .code-base; padding: 1px $-xs; } + +pre code { + background-color: transparent; + border: 0; + font-size: 1em; +} /* * Text colors */ diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index d8dc19ec2..7ce9dbfe5 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,5 +1,5 @@ -
+
{{ csrf_field() }}
@@ -42,10 +42,45 @@
- - @if($errors->has('html')) -
{{ $errors->first('html') }}
+ @if(setting('app-editor') === 'wysiwyg') + + @if($errors->has('html')) +
{{ $errors->first('html') }}
+ @endif + @endif + + @if(setting('app-editor') === 'markdown') +
+ +
+
+ Editor +
+ +
+
+ +
+ +
+
+
Preview
+
+
+
+
+
+ +
+ + + + @if($errors->has('markdown')) +
{{ $errors->first('markdown') }}
+ @endif + @endif
\ No newline at end of file diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index f94623256..eb580bb8b 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -17,29 +17,37 @@
- +
- +

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.

- + +
+
+ +

Select which editor will be used by all users to edit pages.

+

This image should be 43px in height.
Large images will be scaled down.

- +

This should be a hex value.
Leave empty to reset to the default color.

- - + +
@@ -53,14 +61,14 @@
- +
+
diff --git a/tests/Entity/MarkdownTest.php b/tests/Entity/MarkdownTest.php new file mode 100644 index 000000000..eaf4d62c3 --- /dev/null +++ b/tests/Entity/MarkdownTest.php @@ -0,0 +1,51 @@ +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); + } + +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 567dc93ec..f46d73e04 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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; + } }