Dan Brown

Added UI components of page autosaving

...@@ -108,6 +108,17 @@ class PageController extends Controller ...@@ -108,6 +108,17 @@ class PageController extends Controller
108 } 108 }
109 109
110 /** 110 /**
111 + * Get page from an ajax request.
112 + * @param $pageId
113 + * @return \Illuminate\Http\JsonResponse
114 + */
115 + public function getPageAjax($pageId)
116 + {
117 + $page = $this->pageRepo->getById($pageId);
118 + return response()->json($page);
119 + }
120 +
121 + /**
111 * Show the form for editing the specified page. 122 * Show the form for editing the specified page.
112 * @param $bookSlug 123 * @param $bookSlug
113 * @param $pageSlug 124 * @param $pageSlug
...@@ -119,6 +130,24 @@ class PageController extends Controller ...@@ -119,6 +130,24 @@ class PageController extends Controller
119 $page = $this->pageRepo->getBySlug($pageSlug, $book->id); 130 $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
120 $this->checkOwnablePermission('page-update', $page); 131 $this->checkOwnablePermission('page-update', $page);
121 $this->setPageTitle('Editing Page ' . $page->getShortName()); 132 $this->setPageTitle('Editing Page ' . $page->getShortName());
133 + $page->isDraft = false;
134 +
135 + // Check for active editing and drafts
136 + $warnings = [];
137 + if ($this->pageRepo->isPageEditingActive($page, 60)) {
138 + $warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60);
139 + }
140 +
141 + if ($this->pageRepo->hasUserGotPageDraft($page, $this->currentUser->id)) {
142 + $draft = $this->pageRepo->getUserPageDraft($page, $this->currentUser->id);
143 + $page->name = $draft->name;
144 + $page->html = $draft->html;
145 + $page->isDraft = true;
146 + $warnings [] = $this->pageRepo->getUserPageDraftMessage($draft);
147 + }
148 +
149 + if (count($warnings) > 0) session()->flash('warning', implode("\n", $warnings));
150 +
122 return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]); 151 return view('pages/edit', ['page' => $page, 'book' => $book, 'current' => $page]);
123 } 152 }
124 153
...@@ -155,8 +184,9 @@ class PageController extends Controller ...@@ -155,8 +184,9 @@ class PageController extends Controller
155 ]); 184 ]);
156 $page = $this->pageRepo->getById($pageId); 185 $page = $this->pageRepo->getById($pageId);
157 $this->checkOwnablePermission('page-update', $page); 186 $this->checkOwnablePermission('page-update', $page);
158 - $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html'])); 187 + $draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html']));
159 - return response()->json(['status' => 'success', 'message' => 'Draft successfully saved']); 188 + $updateTime = $draft->updated_at->format('H:i');
189 + return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]);
160 } 190 }
161 191
162 /** 192 /**
......
...@@ -77,6 +77,7 @@ Route::group(['middleware' => 'auth'], function () { ...@@ -77,6 +77,7 @@ Route::group(['middleware' => 'auth'], function () {
77 77
78 // Ajax routes 78 // Ajax routes
79 Route::put('/ajax/page/{id}/save-draft', 'PageController@saveUpdateDraft'); 79 Route::put('/ajax/page/{id}/save-draft', 'PageController@saveUpdateDraft');
80 + Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
80 81
81 // Links 82 // Links
82 Route::get('/link/{id}', 'PageController@redirectFromLink'); 83 Route::get('/link/{id}', 'PageController@redirectFromLink');
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
4 use Activity; 4 use Activity;
5 use BookStack\Book; 5 use BookStack\Book;
6 use BookStack\Exceptions\NotFoundException; 6 use BookStack\Exceptions\NotFoundException;
7 +use Carbon\Carbon;
7 use DOMDocument; 8 use DOMDocument;
8 use Illuminate\Support\Str; 9 use Illuminate\Support\Str;
9 use BookStack\Page; 10 use BookStack\Page;
...@@ -259,11 +260,16 @@ class PageRepo extends EntityRepo ...@@ -259,11 +260,16 @@ class PageRepo extends EntityRepo
259 } 260 }
260 261
261 // Update with new details 262 // Update with new details
263 + $userId = auth()->user()->id;
262 $page->fill($input); 264 $page->fill($input);
263 $page->html = $this->formatHtml($input['html']); 265 $page->html = $this->formatHtml($input['html']);
264 $page->text = strip_tags($page->html); 266 $page->text = strip_tags($page->html);
265 - $page->updated_by = auth()->user()->id; 267 + $page->updated_by = $userId;
266 $page->save(); 268 $page->save();
269 +
270 + // Remove all update drafts for this user & page.
271 + $this->userUpdateDraftsQuery($page, $userId)->delete();
272 +
267 return $page; 273 return $page;
268 } 274 }
269 275
...@@ -318,10 +324,7 @@ class PageRepo extends EntityRepo ...@@ -318,10 +324,7 @@ class PageRepo extends EntityRepo
318 public function saveUpdateDraft(Page $page, $data = []) 324 public function saveUpdateDraft(Page $page, $data = [])
319 { 325 {
320 $userId = auth()->user()->id; 326 $userId = auth()->user()->id;
321 - $drafts = $this->pageRevision->where('created_by', '=', $userId) 327 + $drafts = $this->userUpdateDraftsQuery($page, $userId)->get();
322 - ->where('type', 'update_draft')
323 - ->where('page_id', '=', $page->id)
324 - ->orderBy('created_at', 'desc')->get();
325 328
326 if ($drafts->count() > 0) { 329 if ($drafts->count() > 0) {
327 $draft = $drafts->first(); 330 $draft = $drafts->first();
...@@ -340,6 +343,107 @@ class PageRepo extends EntityRepo ...@@ -340,6 +343,107 @@ class PageRepo extends EntityRepo
340 } 343 }
341 344
342 /** 345 /**
346 + * The base query for getting user update drafts.
347 + * @param Page $page
348 + * @param $userId
349 + * @return mixed
350 + */
351 + private function userUpdateDraftsQuery(Page $page, $userId)
352 + {
353 + return $this->pageRevision->where('created_by', '=', $userId)
354 + ->where('type', 'update_draft')
355 + ->where('page_id', '=', $page->id)
356 + ->orderBy('created_at', 'desc');
357 + }
358 +
359 + /**
360 + * Checks whether a user has a draft version of a particular page or not.
361 + * @param Page $page
362 + * @param $userId
363 + * @return bool
364 + */
365 + public function hasUserGotPageDraft(Page $page, $userId)
366 + {
367 + return $this->userUpdateDraftsQuery($page, $userId)->count() > 0;
368 + }
369 +
370 + /**
371 + * Get the latest updated draft revision for a particular page and user.
372 + * @param Page $page
373 + * @param $userId
374 + * @return mixed
375 + */
376 + public function getUserPageDraft(Page $page, $userId)
377 + {
378 + return $this->userUpdateDraftsQuery($page, $userId)->first();
379 + }
380 +
381 + /**
382 + * Get the notification message that informs the user that they are editing a draft page.
383 + * @param PageRevision $draft
384 + * @return string
385 + */
386 + public function getUserPageDraftMessage(PageRevision $draft)
387 + {
388 + $message = 'You are currently editing a draft that was last saved ' . $draft->updated_at->diffForHumans() . '.';
389 + if ($draft->page->updated_at->timestamp > $draft->updated_at->timestamp) {
390 + $message .= "\n This page has been updated by since that time. It is recommended that you discard this draft.";
391 + }
392 + return $message;
393 + }
394 +
395 + /**
396 + * Check if a page is being actively editing.
397 + * Checks for edits since last page updated.
398 + * Passing in a minuted range will check for edits
399 + * within the last x minutes.
400 + * @param Page $page
401 + * @param null $minRange
402 + * @return bool
403 + */
404 + public function isPageEditingActive(Page $page, $minRange = null)
405 + {
406 + $draftSearch = $this->activePageEditingQuery($page, $minRange);
407 + return $draftSearch->count() > 0;
408 + }
409 +
410 + /**
411 + * Get a notification message concerning the editing activity on
412 + * a particular page.
413 + * @param Page $page
414 + * @param null $minRange
415 + * @return string
416 + */
417 + public function getPageEditingActiveMessage(Page $page, $minRange = null)
418 + {
419 + $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
420 + $userMessage = $pageDraftEdits->count() > 1 ? $pageDraftEdits->count() . ' users have' : $pageDraftEdits->first()->createdBy->name . ' has';
421 + $timeMessage = $minRange === null ? 'since the page was last updated' : 'in the last ' . $minRange . ' minutes';
422 + $message = '%s started editing this page %s. Take care not to overwrite each other\'s updates!';
423 + return sprintf($message, $userMessage, $timeMessage);
424 + }
425 +
426 + /**
427 + * A query to check for active update drafts on a particular page.
428 + * @param Page $page
429 + * @param null $minRange
430 + * @return mixed
431 + */
432 + private function activePageEditingQuery(Page $page, $minRange = null)
433 + {
434 + $query = $this->pageRevision->where('type', '=', 'update_draft')
435 + ->where('updated_at', '>', $page->updated_at)
436 + ->where('created_by', '!=', auth()->user()->id)
437 + ->with('createdBy');
438 +
439 + if ($minRange !== null) {
440 + $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
441 + }
442 +
443 + return $query;
444 + }
445 +
446 + /**
343 * Gets a single revision via it's id. 447 * Gets a single revision via it's id.
344 * @param $id 448 * @param $id
345 * @return mixed 449 * @return mixed
......
...@@ -213,49 +213,85 @@ module.exports = function (ngApp, events) { ...@@ -213,49 +213,85 @@ module.exports = function (ngApp, events) {
213 }]); 213 }]);
214 214
215 215
216 - ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', function ($scope, $http, $attrs, $interval) { 216 + ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', function ($scope, $http, $attrs, $interval, $timeout) {
217 217
218 $scope.editorOptions = require('./pages/page-form'); 218 $scope.editorOptions = require('./pages/page-form');
219 $scope.editorHtml = ''; 219 $scope.editorHtml = '';
220 $scope.draftText = ''; 220 $scope.draftText = '';
221 var pageId = Number($attrs.pageId); 221 var pageId = Number($attrs.pageId);
222 var isEdit = pageId !== 0; 222 var isEdit = pageId !== 0;
223 + var autosaveFrequency = 30; // AutoSave interval in seconds.
224 + $scope.isDraft = Number($attrs.pageDraft) === 1;
225 + if ($scope.isDraft) $scope.draftText = 'Editing Draft';
226 +
227 + var autoSave = false;
228 +
229 + var currentContent = {
230 + title: false,
231 + html: false
232 + };
223 233
224 if (isEdit) { 234 if (isEdit) {
235 + setTimeout(() => {
225 startAutoSave(); 236 startAutoSave();
237 + }, 1000);
226 } 238 }
227 239
228 - $scope.editorChange = function() { 240 + $scope.editorChange = function () {}
229 - $scope.draftText = '';
230 - }
231 241
242 + /**
243 + * Start the AutoSave loop, Checks for content change
244 + * before performing the costly AJAX request.
245 + */
232 function startAutoSave() { 246 function startAutoSave() {
233 - var currentTitle = $('#name').val(); 247 + currentContent.title = $('#name').val();
234 - var currentHtml = $scope.editorHtml; 248 + currentContent.html = $scope.editorHtml;
235 249
236 - console.log('Starting auto save'); 250 + autoSave = $interval(() => {
237 -
238 - $interval(() => {
239 var newTitle = $('#name').val(); 251 var newTitle = $('#name').val();
240 var newHtml = $scope.editorHtml; 252 var newHtml = $scope.editorHtml;
241 253
242 - if (newTitle !== currentTitle || newHtml !== currentHtml) { 254 + if (newTitle !== currentContent.title || newHtml !== currentContent.html) {
243 - currentHtml = newHtml; 255 + currentContent.html = newHtml;
244 - currentTitle = newTitle; 256 + currentContent.title = newTitle;
245 saveDraftUpdate(newTitle, newHtml); 257 saveDraftUpdate(newTitle, newHtml);
246 } 258 }
247 - }, 1000*5); 259 + }, 1000 * autosaveFrequency);
248 } 260 }
249 261
262 + /**
263 + * Save a draft update into the system via an AJAX request.
264 + * @param title
265 + * @param html
266 + */
250 function saveDraftUpdate(title, html) { 267 function saveDraftUpdate(title, html) {
251 $http.put('/ajax/page/' + pageId + '/save-draft', { 268 $http.put('/ajax/page/' + pageId + '/save-draft', {
252 name: title, 269 name: title,
253 html: html 270 html: html
254 }).then((responseData) => { 271 }).then((responseData) => {
255 - $scope.draftText = 'Draft saved' 272 + $scope.draftText = responseData.data.message;
256 - }) 273 + $scope.isDraft = true;
274 + });
257 } 275 }
258 276
277 + /**
278 + * Discard the current draft and grab the current page
279 + * content from the system via an AJAX request.
280 + */
281 + $scope.discardDraft = function () {
282 + $http.get('/ajax/page/' + pageId).then((responseData) => {
283 + if (autoSave) $interval.cancel(autoSave);
284 + $scope.draftText = '';
285 + $scope.isDraft = false;
286 + $scope.$broadcast('html-update', responseData.data.html);
287 + $('#name').val(currentContent.title);
288 + $timeout(() => {
289 + startAutoSave();
290 + }, 1000);
291 + events.emit('success', 'Draft discarded, The editor has been updated with the current page content');
292 + });
293 + };
294 +
259 }]); 295 }]);
260 296
261 }; 297 };
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -162,7 +162,7 @@ module.exports = function (ngApp, events) { ...@@ -162,7 +162,7 @@ module.exports = function (ngApp, events) {
162 }; 162 };
163 }]); 163 }]);
164 164
165 - ngApp.directive('tinymce', [function() { 165 + ngApp.directive('tinymce', ['$timeout', function($timeout) {
166 return { 166 return {
167 restrict: 'A', 167 restrict: 'A',
168 scope: { 168 scope: {
...@@ -173,14 +173,24 @@ module.exports = function (ngApp, events) { ...@@ -173,14 +173,24 @@ module.exports = function (ngApp, events) {
173 link: function (scope, element, attrs) { 173 link: function (scope, element, attrs) {
174 174
175 function tinyMceSetup(editor) { 175 function tinyMceSetup(editor) {
176 - editor.on('keyup', (e) => { 176 + editor.on('ExecCommand change NodeChange ObjectResized', (e) => {
177 var content = editor.getContent(); 177 var content = editor.getContent();
178 - console.log(content); 178 + $timeout(() => {
179 - scope.$apply(() => {
180 scope.mceModel = content; 179 scope.mceModel = content;
181 }); 180 });
182 scope.mceChange(content); 181 scope.mceChange(content);
183 }); 182 });
183 +
184 + editor.on('init', (e) => {
185 + scope.mceModel = editor.getContent();
186 + });
187 +
188 + scope.$on('html-update', (event, value) => {
189 + editor.setContent(value);
190 + editor.selection.select(editor.getBody(), true);
191 + editor.selection.collapse(false);
192 + scope.mceModel = editor.getContent();
193 + });
184 } 194 }
185 195
186 scope.tinymce.extraSetups.push(tinyMceSetup); 196 scope.tinymce.extraSetups.push(tinyMceSetup);
......
...@@ -54,10 +54,10 @@ $.expr[":"].contains = $.expr.createPseudo(function (arg) { ...@@ -54,10 +54,10 @@ $.expr[":"].contains = $.expr.createPseudo(function (arg) {
54 // Global jQuery Elements 54 // Global jQuery Elements
55 $(function () { 55 $(function () {
56 56
57 -
58 var notifications = $('.notification'); 57 var notifications = $('.notification');
59 var successNotification = notifications.filter('.pos'); 58 var successNotification = notifications.filter('.pos');
60 var errorNotification = notifications.filter('.neg'); 59 var errorNotification = notifications.filter('.neg');
60 + var warningNotification = notifications.filter('.warning');
61 // Notification Events 61 // Notification Events
62 window.Events.listen('success', function (text) { 62 window.Events.listen('success', function (text) {
63 successNotification.hide(); 63 successNotification.hide();
...@@ -66,6 +66,10 @@ $(function () { ...@@ -66,6 +66,10 @@ $(function () {
66 successNotification.show(); 66 successNotification.show();
67 }, 1); 67 }, 1);
68 }); 68 });
69 + window.Events.listen('warning', function (text) {
70 + warningNotification.find('span').text(text);
71 + warningNotification.show();
72 + });
69 window.Events.listen('error', function (text) { 73 window.Events.listen('error', function (text) {
70 errorNotification.find('span').text(text); 74 errorNotification.find('span').text(text);
71 errorNotification.show(); 75 errorNotification.show();
......
...@@ -54,8 +54,6 @@ var mceOptions = module.exports = { ...@@ -54,8 +54,6 @@ var mceOptions = module.exports = {
54 extraSetups: [], 54 extraSetups: [],
55 setup: function (editor) { 55 setup: function (editor) {
56 56
57 - console.log(mceOptions.extraSetups);
58 -
59 for (var i = 0; i < mceOptions.extraSetups.length; i++) { 57 for (var i = 0; i < mceOptions.extraSetups.length; i++) {
60 mceOptions.extraSetups[i](editor); 58 mceOptions.extraSetups[i](editor);
61 } 59 }
......
...@@ -161,6 +161,12 @@ form.search-box { ...@@ -161,6 +161,12 @@ form.search-box {
161 } 161 }
162 } 162 }
163 163
164 +.faded > span.faded-text {
165 + display: inline-block;
166 + padding: $-s;
167 + opacity: 0.5;
168 +}
169 +
164 .faded-small { 170 .faded-small {
165 color: #000; 171 color: #000;
166 font-size: 0.9em; 172 font-size: 0.9em;
......
...@@ -38,6 +38,7 @@ $primary-dark: #0288D1; ...@@ -38,6 +38,7 @@ $primary-dark: #0288D1;
38 $secondary: #e27b41; 38 $secondary: #e27b41;
39 $positive: #52A256; 39 $positive: #52A256;
40 $negative: #E84F4F; 40 $negative: #E84F4F;
41 +$warning: $secondary;
41 $primary-faded: rgba(21, 101, 192, 0.15); 42 $primary-faded: rgba(21, 101, 192, 0.15);
42 43
43 // Item Colors 44 // Item Colors
......
...@@ -88,6 +88,10 @@ body.dragging, body.dragging * { ...@@ -88,6 +88,10 @@ body.dragging, body.dragging * {
88 background-color: $negative; 88 background-color: $negative;
89 color: #EEE; 89 color: #EEE;
90 } 90 }
91 + &.warning {
92 + background-color: $secondary;
93 + color: #EEE;
94 + }
91 } 95 }
92 96
93 // Loading icon 97 // Loading icon
......
1 1
2 2
3 3
4 -<div class="page-editor flex-fill flex" ng-controller="PageEditController" page-id="{{ $model->id or 0 }}"> 4 +<div class="page-editor flex-fill flex" ng-controller="PageEditController" page-id="{{ $model->id or 0 }}" page-draft="{{ $page->isDraft or 0 }}">
5 5
6 {{ csrf_field() }} 6 {{ csrf_field() }}
7 <div class="faded-small toolbar"> 7 <div class="faded-small toolbar">
...@@ -9,15 +9,19 @@ ...@@ -9,15 +9,19 @@
9 <div class="row"> 9 <div class="row">
10 <div class="col-sm-4 faded"> 10 <div class="col-sm-4 faded">
11 <div class="action-buttons text-left"> 11 <div class="action-buttons text-left">
12 + <a href="{{ back()->getTargetUrl() }}" class="text-button text-primary"><i class="zmdi zmdi-arrow-left"></i>Back</a>
12 <a onclick="$('body>header').slideToggle();" class="text-button text-primary"><i class="zmdi zmdi-swap-vertical"></i>Toggle Header</a> 13 <a onclick="$('body>header').slideToggle();" class="text-button text-primary"><i class="zmdi zmdi-swap-vertical"></i>Toggle Header</a>
13 </div> 14 </div>
14 </div> 15 </div>
15 <div class="col-sm-4 faded text-center"> 16 <div class="col-sm-4 faded text-center">
16 - <span ng-bind="draftText"></span> 17 + <div class="action-buttons text-center" ng-cloak>
18 + <span class="faded-text" ng-bind="draftText"></span>
19 + <button type="button" ng-if="isDraft" ng-click="discardDraft()" class="text-button text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</button>
20 + </div>
17 </div> 21 </div>
18 <div class="col-sm-4 faded"> 22 <div class="col-sm-4 faded">
19 <div class="action-buttons"> 23 <div class="action-buttons">
20 - <a href="{{ back()->getTargetUrl() }}" class="text-button text-primary"><i class="zmdi zmdi-close"></i>Cancel</a> 24 +
21 <button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button> 25 <button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button>
22 </div> 26 </div>
23 </div> 27 </div>
......
1 1
2 <div class="notification anim pos" @if(!Session::has('success')) style="display:none;" @endif> 2 <div class="notification anim pos" @if(!Session::has('success')) style="display:none;" @endif>
3 - <i class="zmdi zmdi-check-circle"></i> <span>{{ Session::get('success') }}</span> 3 + <i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(Session::get('success'))) !!}</span>
4 +</div>
5 +
6 +<div class="notification anim warning stopped" @if(!Session::has('warning')) style="display:none;" @endif>
7 + <i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(Session::get('warning'))) !!}</span>
4 </div> 8 </div>
5 9
6 <div class="notification anim neg stopped" @if(!Session::has('error')) style="display:none;" @endif> 10 <div class="notification anim neg stopped" @if(!Session::has('error')) style="display:none;" @endif>
7 - <i class="zmdi zmdi-alert-circle"></i> <span>{{ Session::get('error') }}</span> 11 + <i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(Session::get('error'))) !!}</span>
8 </div> 12 </div>
......