diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index c6228a8bc..04065996e 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -107,6 +107,17 @@ class PageController extends Controller return view('pages/show', ['page' => $page, 'book' => $book, 'current' => $page, 'sidebarTree' => $sidebarTree]); } + /** + * Get page from an ajax request. + * @param $pageId + * @return \Illuminate\Http\JsonResponse + */ + public function getPageAjax($pageId) + { + $page = $this->pageRepo->getById($pageId); + return response()->json($page); + } + /** * Show the form for editing the specified page. * @param $bookSlug @@ -119,6 +130,24 @@ class PageController extends Controller $page = $this->pageRepo->getBySlug($pageSlug, $book->id); $this->checkOwnablePermission('page-update', $page); $this->setPageTitle('Editing Page ' . $page->getShortName()); + $page->isDraft = false; + + // Check for active editing and drafts + $warnings = []; + if ($this->pageRepo->isPageEditingActive($page, 60)) { + $warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60); + } + + if ($this->pageRepo->hasUserGotPageDraft($page, $this->currentUser->id)) { + $draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id); + $page->name = $draft->name; + $page->html = $draft->html; + $page->isDraft = true; + $warnings [] = $this->pageRepo->getUserPageDraftMessage($draft); + } + + if (count($warnings) > 0) session()->flash('warning', implode("\n", $warnings)); + return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]); } @@ -155,8 +184,9 @@ class PageController extends Controller ]); $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-update', $page); - $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html'])); - return response()->json(['status' => 'success', 'message' => 'Draft successfully saved']); + $draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html'])); + $updateTime = $draft->updated_at->format('H:i'); + return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]); } /** diff --git a/app/Http/routes.php b/app/Http/routes.php index e16d4f8f9..48765be88 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -77,6 +77,7 @@ Route::group(['middleware' => 'auth'], function () { // Ajax routes Route::put('/ajax/page/{id}/save-draft', 'PageController@saveUpdateDraft'); + Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); // Links Route::get('/link/{id}', 'PageController@redirectFromLink'); diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index ca97fc1e9..776d1eadf 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -4,6 +4,7 @@ use Activity; use BookStack\Book; use BookStack\Exceptions\NotFoundException; +use Carbon\Carbon; use DOMDocument; use Illuminate\Support\Str; use BookStack\Page; @@ -259,11 +260,16 @@ class PageRepo extends EntityRepo } // Update with new details + $userId = auth()->user()->id; $page->fill($input); $page->html = $this->formatHtml($input['html']); $page->text = strip_tags($page->html); - $page->updated_by = auth()->user()->id; + $page->updated_by = $userId; $page->save(); + + // Remove all update drafts for this user & page. + $this->userUpdateDraftsQuery($page, $userId)->delete(); + return $page; } @@ -318,10 +324,7 @@ class PageRepo extends EntityRepo public function saveUpdateDraft(Page $page, $data = []) { $userId = auth()->user()->id; - $drafts = $this->pageRevision->where('created_by', '=', $userId) - ->where('type', 'update_draft') - ->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc')->get(); + $drafts = $this->userUpdateDraftsQuery($page, $userId)->get(); if ($drafts->count() > 0) { $draft = $drafts->first(); @@ -339,6 +342,107 @@ class PageRepo extends EntityRepo return $draft; } + /** + * The base query for getting user update drafts. + * @param Page $page + * @param $userId + * @return mixed + */ + private function userUpdateDraftsQuery(Page $page, $userId) + { + return $this->pageRevision->where('created_by', '=', $userId) + ->where('type', 'update_draft') + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc'); + } + + /** + * Checks whether a user has a draft version of a particular page or not. + * @param Page $page + * @param $userId + * @return bool + */ + public function hasUserGotPageDraft(Page $page, $userId) + { + return $this->userUpdateDraftsQuery($page, $userId)->count() > 0; + } + + /** + * Get the latest updated draft revision for a particular page and user. + * @param Page $page + * @param $userId + * @return mixed + */ + public function getUserPageDraft(Page $page, $userId) + { + return $this->userUpdateDraftsQuery($page, $userId)->first(); + } + + /** + * Get the notification message that informs the user that they are editing a draft page. + * @param PageRevision $draft + * @return string + */ + public function getUserPageDraftMessage(PageRevision $draft) + { + $message = 'You are currently editing a draft that was last saved ' . $draft->updated_at->diffForHumans() . '.'; + if ($draft->page->updated_at->timestamp > $draft->updated_at->timestamp) { + $message .= "\n This page has been updated by since that time. It is recommended that you discard this draft."; + } + return $message; + } + + /** + * Check if a page is being actively editing. + * Checks for edits since last page updated. + * Passing in a minuted range will check for edits + * within the last x minutes. + * @param Page $page + * @param null $minRange + * @return bool + */ + public function isPageEditingActive(Page $page, $minRange = null) + { + $draftSearch = $this->activePageEditingQuery($page, $minRange); + return $draftSearch->count() > 0; + } + + /** + * Get a notification message concerning the editing activity on + * a particular page. + * @param Page $page + * @param null $minRange + * @return string + */ + public function getPageEditingActiveMessage(Page $page, $minRange = null) + { + $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get(); + $userMessage = $pageDraftEdits->count() > 1 ? $pageDraftEdits->count() . ' users have' : $pageDraftEdits->first()->createdBy->name . ' has'; + $timeMessage = $minRange === null ? 'since the page was last updated' : 'in the last ' . $minRange . ' minutes'; + $message = '%s started editing this page %s. Take care not to overwrite each other\'s updates!'; + return sprintf($message, $userMessage, $timeMessage); + } + + /** + * A query to check for active update drafts on a particular page. + * @param Page $page + * @param null $minRange + * @return mixed + */ + private function activePageEditingQuery(Page $page, $minRange = null) + { + $query = $this->pageRevision->where('type', '=', 'update_draft') + ->where('updated_at', '>', $page->updated_at) + ->where('created_by', '!=', auth()->user()->id) + ->with('createdBy'); + + if ($minRange !== null) { + $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange)); + } + + return $query; + } + /** * Gets a single revision via it's id. * @param $id diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 305e0c3c1..76b8cc67d 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -213,49 +213,85 @@ module.exports = function (ngApp, events) { }]); - ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', function ($scope, $http, $attrs, $interval) { + ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', function ($scope, $http, $attrs, $interval, $timeout) { $scope.editorOptions = require('./pages/page-form'); $scope.editorHtml = ''; $scope.draftText = ''; var pageId = Number($attrs.pageId); var isEdit = pageId !== 0; + var autosaveFrequency = 30; // AutoSave interval in seconds. + $scope.isDraft = Number($attrs.pageDraft) === 1; + if ($scope.isDraft) $scope.draftText = 'Editing Draft'; + + var autoSave = false; + + var currentContent = { + title: false, + html: false + }; if (isEdit) { - startAutoSave(); + setTimeout(() => { + startAutoSave(); + }, 1000); } - $scope.editorChange = function() { - $scope.draftText = ''; - } + $scope.editorChange = function () {} + /** + * Start the AutoSave loop, Checks for content change + * before performing the costly AJAX request. + */ function startAutoSave() { - var currentTitle = $('#name').val(); - var currentHtml = $scope.editorHtml; + currentContent.title = $('#name').val(); + currentContent.html = $scope.editorHtml; - console.log('Starting auto save'); - - $interval(() => { + autoSave = $interval(() => { var newTitle = $('#name').val(); var newHtml = $scope.editorHtml; - if (newTitle !== currentTitle || newHtml !== currentHtml) { - currentHtml = newHtml; - currentTitle = newTitle; + if (newTitle !== currentContent.title || newHtml !== currentContent.html) { + currentContent.html = newHtml; + currentContent.title = newTitle; saveDraftUpdate(newTitle, newHtml); } - }, 1000*5); + }, 1000 * autosaveFrequency); } + /** + * Save a draft update into the system via an AJAX request. + * @param title + * @param html + */ function saveDraftUpdate(title, html) { $http.put('/ajax/page/' + pageId + '/save-draft', { name: title, html: html }).then((responseData) => { - $scope.draftText = 'Draft saved' - }) + $scope.draftText = responseData.data.message; + $scope.isDraft = true; + }); } + /** + * Discard the current draft and grab the current page + * content from the system via an AJAX request. + */ + $scope.discardDraft = function () { + $http.get('/ajax/page/' + pageId).then((responseData) => { + if (autoSave) $interval.cancel(autoSave); + $scope.draftText = ''; + $scope.isDraft = false; + $scope.$broadcast('html-update', responseData.data.html); + $('#name').val(currentContent.title); + $timeout(() => { + startAutoSave(); + }, 1000); + events.emit('success', 'Draft discarded, The editor has been updated with the current page content'); + }); + }; + }]); }; \ No newline at end of file diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index dee02ab40..72d35d455 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -162,7 +162,7 @@ module.exports = function (ngApp, events) { }; }]); - ngApp.directive('tinymce', [function() { + ngApp.directive('tinymce', ['$timeout', function($timeout) { return { restrict: 'A', scope: { @@ -173,14 +173,24 @@ module.exports = function (ngApp, events) { link: function (scope, element, attrs) { function tinyMceSetup(editor) { - editor.on('keyup', (e) => { + editor.on('ExecCommand change NodeChange ObjectResized', (e) => { var content = editor.getContent(); - console.log(content); - scope.$apply(() => { + $timeout(() => { scope.mceModel = content; }); scope.mceChange(content); }); + + editor.on('init', (e) => { + scope.mceModel = editor.getContent(); + }); + + scope.$on('html-update', (event, value) => { + editor.setContent(value); + editor.selection.select(editor.getBody(), true); + editor.selection.collapse(false); + scope.mceModel = editor.getContent(); + }); } scope.tinymce.extraSetups.push(tinyMceSetup); diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index aa5e60ce4..9e2b3b8ea 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -54,10 +54,10 @@ $.expr[":"].contains = $.expr.createPseudo(function (arg) { // Global jQuery Elements $(function () { - var notifications = $('.notification'); var successNotification = notifications.filter('.pos'); var errorNotification = notifications.filter('.neg'); + var warningNotification = notifications.filter('.warning'); // Notification Events window.Events.listen('success', function (text) { successNotification.hide(); @@ -66,6 +66,10 @@ $(function () { successNotification.show(); }, 1); }); + window.Events.listen('warning', function (text) { + warningNotification.find('span').text(text); + warningNotification.show(); + }); window.Events.listen('error', function (text) { errorNotification.find('span').text(text); errorNotification.show(); diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js index 0310b5fa2..c6787ba87 100644 --- a/resources/assets/js/pages/page-form.js +++ b/resources/assets/js/pages/page-form.js @@ -54,8 +54,6 @@ var mceOptions = module.exports = { extraSetups: [], setup: function (editor) { - console.log(mceOptions.extraSetups); - for (var i = 0; i < mceOptions.extraSetups.length; i++) { mceOptions.extraSetups[i](editor); } diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss index 87aa20046..938464228 100644 --- a/resources/assets/sass/_header.scss +++ b/resources/assets/sass/_header.scss @@ -161,6 +161,12 @@ form.search-box { } } +.faded > span.faded-text { + display: inline-block; + padding: $-s; + opacity: 0.5; +} + .faded-small { color: #000; font-size: 0.9em; diff --git a/resources/assets/sass/_variables.scss b/resources/assets/sass/_variables.scss index 874515bfd..cb6cec9c1 100644 --- a/resources/assets/sass/_variables.scss +++ b/resources/assets/sass/_variables.scss @@ -38,6 +38,7 @@ $primary-dark: #0288D1; $secondary: #e27b41; $positive: #52A256; $negative: #E84F4F; +$warning: $secondary; $primary-faded: rgba(21, 101, 192, 0.15); // Item Colors diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 9c4a4dafc..7c7821242 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -88,6 +88,10 @@ body.dragging, body.dragging * { background-color: $negative; color: #EEE; } + &.warning { + background-color: $secondary; + color: #EEE; + } } // Loading icon diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index f406247d7..b8194cda7 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,7 +1,7 @@ -
+
{{ csrf_field() }}
@@ -9,15 +9,19 @@
- +
+ + +
- Cancel +
diff --git a/resources/views/partials/notifications.blade.php b/resources/views/partials/notifications.blade.php index 8cc0774c9..183934c66 100644 --- a/resources/views/partials/notifications.blade.php +++ b/resources/views/partials/notifications.blade.php @@ -1,8 +1,12 @@ + +