Showing
24 changed files
with
473 additions
and
38 deletions
| ... | @@ -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 | ||
| ... | @@ -143,6 +172,24 @@ class PageController extends Controller | ... | @@ -143,6 +172,24 @@ class PageController extends Controller |
| 143 | } | 172 | } |
| 144 | 173 | ||
| 145 | /** | 174 | /** |
| 175 | + * Save a draft update as a revision. | ||
| 176 | + * @param Request $request | ||
| 177 | + * @param $pageId | ||
| 178 | + * @return \Illuminate\Http\JsonResponse | ||
| 179 | + */ | ||
| 180 | + public function saveUpdateDraft(Request $request, $pageId) | ||
| 181 | + { | ||
| 182 | + $this->validate($request, [ | ||
| 183 | + 'name' => 'required|string|max:255' | ||
| 184 | + ]); | ||
| 185 | + $page = $this->pageRepo->getById($pageId); | ||
| 186 | + $this->checkOwnablePermission('page-update', $page); | ||
| 187 | + $draft = $this->pageRepo->saveUpdateDraft($page, $request->only(['name', 'html'])); | ||
| 188 | + $updateTime = $draft->updated_at->format('H:i'); | ||
| 189 | + return response()->json(['status' => 'success', 'message' => 'Draft saved at ' . $updateTime]); | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + /** | ||
| 146 | * Redirect from a special link url which | 193 | * Redirect from a special link url which |
| 147 | * uses the page id rather than the name. | 194 | * uses the page id rather than the name. |
| 148 | * @param $pageId | 195 | * @param $pageId | ... | ... |
| ... | @@ -75,6 +75,10 @@ Route::group(['middleware' => 'auth'], function () { | ... | @@ -75,6 +75,10 @@ Route::group(['middleware' => 'auth'], function () { |
| 75 | Route::delete('/{imageId}', 'ImageController@destroy'); | 75 | Route::delete('/{imageId}', 'ImageController@destroy'); |
| 76 | }); | 76 | }); |
| 77 | 77 | ||
| 78 | + // Ajax routes | ||
| 79 | + Route::put('/ajax/page/{id}/save-draft', 'PageController@saveUpdateDraft'); | ||
| 80 | + Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); | ||
| 81 | + | ||
| 78 | // Links | 82 | // Links |
| 79 | Route::get('/link/{id}', 'PageController@redirectFromLink'); | 83 | Route::get('/link/{id}', 'PageController@redirectFromLink'); |
| 80 | 84 | ... | ... |
| ... | @@ -34,7 +34,7 @@ class Page extends Entity | ... | @@ -34,7 +34,7 @@ class Page extends Entity |
| 34 | 34 | ||
| 35 | public function revisions() | 35 | public function revisions() |
| 36 | { | 36 | { |
| 37 | - return $this->hasMany('BookStack\PageRevision')->orderBy('created_at', 'desc'); | 37 | + return $this->hasMany('BookStack\PageRevision')->where('type', '=', 'version')->orderBy('created_at', 'desc'); |
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | public function getUrl() | 40 | public function getUrl() | ... | ... |
| 1 | -<?php | 1 | +<?php namespace BookStack; |
| 2 | - | ||
| 3 | -namespace BookStack; | ||
| 4 | 2 | ||
| 5 | use Illuminate\Database\Eloquent\Model; | 3 | use Illuminate\Database\Eloquent\Model; |
| 6 | 4 | ||
| ... | @@ -8,16 +6,28 @@ class PageRevision extends Model | ... | @@ -8,16 +6,28 @@ class PageRevision extends Model |
| 8 | { | 6 | { |
| 9 | protected $fillable = ['name', 'html', 'text']; | 7 | protected $fillable = ['name', 'html', 'text']; |
| 10 | 8 | ||
| 9 | + /** | ||
| 10 | + * Get the user that created the page revision | ||
| 11 | + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo | ||
| 12 | + */ | ||
| 11 | public function createdBy() | 13 | public function createdBy() |
| 12 | { | 14 | { |
| 13 | return $this->belongsTo('BookStack\User', 'created_by'); | 15 | return $this->belongsTo('BookStack\User', 'created_by'); |
| 14 | } | 16 | } |
| 15 | 17 | ||
| 18 | + /** | ||
| 19 | + * Get the page this revision originates from. | ||
| 20 | + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo | ||
| 21 | + */ | ||
| 16 | public function page() | 22 | public function page() |
| 17 | { | 23 | { |
| 18 | return $this->belongsTo('BookStack\Page'); | 24 | return $this->belongsTo('BookStack\Page'); |
| 19 | } | 25 | } |
| 20 | 26 | ||
| 27 | + /** | ||
| 28 | + * Get the url for this revision. | ||
| 29 | + * @return string | ||
| 30 | + */ | ||
| 21 | public function getUrl() | 31 | public function getUrl() |
| 22 | { | 32 | { |
| 23 | return $this->page->getUrl() . '/revisions/' . $this->id; | 33 | return $this->page->getUrl() . '/revisions/' . $this->id; | ... | ... |
| ... | @@ -4,6 +4,8 @@ | ... | @@ -4,6 +4,8 @@ |
| 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; | ||
| 8 | +use DOMDocument; | ||
| 7 | use Illuminate\Support\Str; | 9 | use Illuminate\Support\Str; |
| 8 | use BookStack\Page; | 10 | use BookStack\Page; |
| 9 | use BookStack\PageRevision; | 11 | use BookStack\PageRevision; |
| ... | @@ -66,9 +68,10 @@ class PageRepo extends EntityRepo | ... | @@ -66,9 +68,10 @@ class PageRepo extends EntityRepo |
| 66 | public function findPageUsingOldSlug($pageSlug, $bookSlug) | 68 | public function findPageUsingOldSlug($pageSlug, $bookSlug) |
| 67 | { | 69 | { |
| 68 | $revision = $this->pageRevision->where('slug', '=', $pageSlug) | 70 | $revision = $this->pageRevision->where('slug', '=', $pageSlug) |
| 69 | - ->whereHas('page', function($query) { | 71 | + ->whereHas('page', function ($query) { |
| 70 | $this->restrictionService->enforcePageRestrictions($query); | 72 | $this->restrictionService->enforcePageRestrictions($query); |
| 71 | }) | 73 | }) |
| 74 | + ->where('type', '=', 'version') | ||
| 72 | ->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc') | 75 | ->where('book_slug', '=', $bookSlug)->orderBy('created_at', 'desc') |
| 73 | ->with('page')->first(); | 76 | ->with('page')->first(); |
| 74 | return $revision !== null ? $revision->page : null; | 77 | return $revision !== null ? $revision->page : null; |
| ... | @@ -100,8 +103,8 @@ class PageRepo extends EntityRepo | ... | @@ -100,8 +103,8 @@ class PageRepo extends EntityRepo |
| 100 | * Save a new page into the system. | 103 | * Save a new page into the system. |
| 101 | * Input validation must be done beforehand. | 104 | * Input validation must be done beforehand. |
| 102 | * @param array $input | 105 | * @param array $input |
| 103 | - * @param Book $book | 106 | + * @param Book $book |
| 104 | - * @param int $chapterId | 107 | + * @param int $chapterId |
| 105 | * @return Page | 108 | * @return Page |
| 106 | */ | 109 | */ |
| 107 | public function saveNew(array $input, Book $book, $chapterId = null) | 110 | public function saveNew(array $input, Book $book, $chapterId = null) |
| ... | @@ -128,9 +131,9 @@ class PageRepo extends EntityRepo | ... | @@ -128,9 +131,9 @@ class PageRepo extends EntityRepo |
| 128 | */ | 131 | */ |
| 129 | protected function formatHtml($htmlText) | 132 | protected function formatHtml($htmlText) |
| 130 | { | 133 | { |
| 131 | - if($htmlText == '') return $htmlText; | 134 | + if ($htmlText == '') return $htmlText; |
| 132 | libxml_use_internal_errors(true); | 135 | libxml_use_internal_errors(true); |
| 133 | - $doc = new \DOMDocument(); | 136 | + $doc = new DOMDocument(); |
| 134 | $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); | 137 | $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); |
| 135 | 138 | ||
| 136 | $container = $doc->documentElement; | 139 | $container = $doc->documentElement; |
| ... | @@ -239,8 +242,8 @@ class PageRepo extends EntityRepo | ... | @@ -239,8 +242,8 @@ class PageRepo extends EntityRepo |
| 239 | 242 | ||
| 240 | /** | 243 | /** |
| 241 | * Updates a page with any fillable data and saves it into the database. | 244 | * Updates a page with any fillable data and saves it into the database. |
| 242 | - * @param Page $page | 245 | + * @param Page $page |
| 243 | - * @param int $book_id | 246 | + * @param int $book_id |
| 244 | * @param string $input | 247 | * @param string $input |
| 245 | * @return Page | 248 | * @return Page |
| 246 | */ | 249 | */ |
| ... | @@ -257,11 +260,16 @@ class PageRepo extends EntityRepo | ... | @@ -257,11 +260,16 @@ class PageRepo extends EntityRepo |
| 257 | } | 260 | } |
| 258 | 261 | ||
| 259 | // Update with new details | 262 | // Update with new details |
| 263 | + $userId = auth()->user()->id; | ||
| 260 | $page->fill($input); | 264 | $page->fill($input); |
| 261 | $page->html = $this->formatHtml($input['html']); | 265 | $page->html = $this->formatHtml($input['html']); |
| 262 | $page->text = strip_tags($page->html); | 266 | $page->text = strip_tags($page->html); |
| 263 | - $page->updated_by = auth()->user()->id; | 267 | + $page->updated_by = $userId; |
| 264 | $page->save(); | 268 | $page->save(); |
| 269 | + | ||
| 270 | + // Remove all update drafts for this user & page. | ||
| 271 | + $this->userUpdateDraftsQuery($page, $userId)->delete(); | ||
| 272 | + | ||
| 265 | return $page; | 273 | return $page; |
| 266 | } | 274 | } |
| 267 | 275 | ||
| ... | @@ -297,6 +305,7 @@ class PageRepo extends EntityRepo | ... | @@ -297,6 +305,7 @@ class PageRepo extends EntityRepo |
| 297 | $revision->book_slug = $page->book->slug; | 305 | $revision->book_slug = $page->book->slug; |
| 298 | $revision->created_by = auth()->user()->id; | 306 | $revision->created_by = auth()->user()->id; |
| 299 | $revision->created_at = $page->updated_at; | 307 | $revision->created_at = $page->updated_at; |
| 308 | + $revision->type = 'version'; | ||
| 300 | $revision->save(); | 309 | $revision->save(); |
| 301 | // Clear old revisions | 310 | // Clear old revisions |
| 302 | if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) { | 311 | if ($this->pageRevision->where('page_id', '=', $page->id)->count() > 50) { |
| ... | @@ -307,6 +316,134 @@ class PageRepo extends EntityRepo | ... | @@ -307,6 +316,134 @@ class PageRepo extends EntityRepo |
| 307 | } | 316 | } |
| 308 | 317 | ||
| 309 | /** | 318 | /** |
| 319 | + * Save a page update draft. | ||
| 320 | + * @param Page $page | ||
| 321 | + * @param array $data | ||
| 322 | + * @return PageRevision | ||
| 323 | + */ | ||
| 324 | + public function saveUpdateDraft(Page $page, $data = []) | ||
| 325 | + { | ||
| 326 | + $userId = auth()->user()->id; | ||
| 327 | + $drafts = $this->userUpdateDraftsQuery($page, $userId)->get(); | ||
| 328 | + | ||
| 329 | + if ($drafts->count() > 0) { | ||
| 330 | + $draft = $drafts->first(); | ||
| 331 | + } else { | ||
| 332 | + $draft = $this->pageRevision->newInstance(); | ||
| 333 | + $draft->page_id = $page->id; | ||
| 334 | + $draft->slug = $page->slug; | ||
| 335 | + $draft->book_slug = $page->book->slug; | ||
| 336 | + $draft->created_by = $userId; | ||
| 337 | + $draft->type = 'update_draft'; | ||
| 338 | + } | ||
| 339 | + | ||
| 340 | + $draft->fill($data); | ||
| 341 | + $draft->save(); | ||
| 342 | + return $draft; | ||
| 343 | + } | ||
| 344 | + | ||
| 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 | + /** | ||
| 310 | * Gets a single revision via it's id. | 447 | * Gets a single revision via it's id. |
| 311 | * @param $id | 448 | * @param $id |
| 312 | * @return mixed | 449 | * @return mixed |
| ... | @@ -333,7 +470,7 @@ class PageRepo extends EntityRepo | ... | @@ -333,7 +470,7 @@ class PageRepo extends EntityRepo |
| 333 | /** | 470 | /** |
| 334 | * Changes the related book for the specified page. | 471 | * Changes the related book for the specified page. |
| 335 | * Changes the book id of any relations to the page that store the book id. | 472 | * Changes the book id of any relations to the page that store the book id. |
| 336 | - * @param int $bookId | 473 | + * @param int $bookId |
| 337 | * @param Page $page | 474 | * @param Page $page |
| 338 | * @return Page | 475 | * @return Page |
| 339 | */ | 476 | */ | ... | ... |
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +use Illuminate\Database\Schema\Blueprint; | ||
| 4 | +use Illuminate\Database\Migrations\Migration; | ||
| 5 | + | ||
| 6 | +class AddPageRevisionTypes extends Migration | ||
| 7 | +{ | ||
| 8 | + /** | ||
| 9 | + * Run the migrations. | ||
| 10 | + * | ||
| 11 | + * @return void | ||
| 12 | + */ | ||
| 13 | + public function up() | ||
| 14 | + { | ||
| 15 | + Schema::table('page_revisions', function (Blueprint $table) { | ||
| 16 | + $table->string('type')->default('version'); | ||
| 17 | + $table->index('type'); | ||
| 18 | + }); | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + /** | ||
| 22 | + * Reverse the migrations. | ||
| 23 | + * | ||
| 24 | + * @return void | ||
| 25 | + */ | ||
| 26 | + public function down() | ||
| 27 | + { | ||
| 28 | + Schema::table('page_revisions', function (Blueprint $table) { | ||
| 29 | + $table->dropColumn('type'); | ||
| 30 | + }); | ||
| 31 | + } | ||
| 32 | +} |
public/uploads/.gitignore
100644 → 100755
File mode changed
| ... | @@ -213,4 +213,85 @@ module.exports = function (ngApp, events) { | ... | @@ -213,4 +213,85 @@ module.exports = function (ngApp, events) { |
| 213 | }]); | 213 | }]); |
| 214 | 214 | ||
| 215 | 215 | ||
| 216 | + ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', function ($scope, $http, $attrs, $interval, $timeout) { | ||
| 217 | + | ||
| 218 | + $scope.editorOptions = require('./pages/page-form'); | ||
| 219 | + $scope.editorHtml = ''; | ||
| 220 | + $scope.draftText = ''; | ||
| 221 | + var pageId = Number($attrs.pageId); | ||
| 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 | + }; | ||
| 233 | + | ||
| 234 | + if (isEdit) { | ||
| 235 | + setTimeout(() => { | ||
| 236 | + startAutoSave(); | ||
| 237 | + }, 1000); | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + $scope.editorChange = function () {} | ||
| 241 | + | ||
| 242 | + /** | ||
| 243 | + * Start the AutoSave loop, Checks for content change | ||
| 244 | + * before performing the costly AJAX request. | ||
| 245 | + */ | ||
| 246 | + function startAutoSave() { | ||
| 247 | + currentContent.title = $('#name').val(); | ||
| 248 | + currentContent.html = $scope.editorHtml; | ||
| 249 | + | ||
| 250 | + autoSave = $interval(() => { | ||
| 251 | + var newTitle = $('#name').val(); | ||
| 252 | + var newHtml = $scope.editorHtml; | ||
| 253 | + | ||
| 254 | + if (newTitle !== currentContent.title || newHtml !== currentContent.html) { | ||
| 255 | + currentContent.html = newHtml; | ||
| 256 | + currentContent.title = newTitle; | ||
| 257 | + saveDraftUpdate(newTitle, newHtml); | ||
| 258 | + } | ||
| 259 | + }, 1000 * autosaveFrequency); | ||
| 260 | + } | ||
| 261 | + | ||
| 262 | + /** | ||
| 263 | + * Save a draft update into the system via an AJAX request. | ||
| 264 | + * @param title | ||
| 265 | + * @param html | ||
| 266 | + */ | ||
| 267 | + function saveDraftUpdate(title, html) { | ||
| 268 | + $http.put('/ajax/page/' + pageId + '/save-draft', { | ||
| 269 | + name: title, | ||
| 270 | + html: html | ||
| 271 | + }).then((responseData) => { | ||
| 272 | + $scope.draftText = responseData.data.message; | ||
| 273 | + $scope.isDraft = true; | ||
| 274 | + }); | ||
| 275 | + } | ||
| 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 | + | ||
| 295 | + }]); | ||
| 296 | + | ||
| 216 | }; | 297 | }; |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -162,5 +162,42 @@ module.exports = function (ngApp, events) { | ... | @@ -162,5 +162,42 @@ module.exports = function (ngApp, events) { |
| 162 | }; | 162 | }; |
| 163 | }]); | 163 | }]); |
| 164 | 164 | ||
| 165 | + ngApp.directive('tinymce', ['$timeout', function($timeout) { | ||
| 166 | + return { | ||
| 167 | + restrict: 'A', | ||
| 168 | + scope: { | ||
| 169 | + tinymce: '=', | ||
| 170 | + mceModel: '=', | ||
| 171 | + mceChange: '=' | ||
| 172 | + }, | ||
| 173 | + link: function (scope, element, attrs) { | ||
| 174 | + | ||
| 175 | + function tinyMceSetup(editor) { | ||
| 176 | + editor.on('ExecCommand change NodeChange ObjectResized', (e) => { | ||
| 177 | + var content = editor.getContent(); | ||
| 178 | + $timeout(() => { | ||
| 179 | + scope.mceModel = content; | ||
| 180 | + }); | ||
| 181 | + scope.mceChange(content); | ||
| 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 | + }); | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + scope.tinymce.extraSetups.push(tinyMceSetup); | ||
| 197 | + tinymce.init(scope.tinymce); | ||
| 198 | + } | ||
| 199 | + } | ||
| 200 | + }]) | ||
| 201 | + | ||
| 165 | 202 | ||
| 166 | }; | 203 | }; |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -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(); |
| ... | @@ -119,11 +123,5 @@ function elemExists(selector) { | ... | @@ -119,11 +123,5 @@ function elemExists(selector) { |
| 119 | return document.querySelector(selector) !== null; | 123 | return document.querySelector(selector) !== null; |
| 120 | } | 124 | } |
| 121 | 125 | ||
| 122 | -// TinyMCE editor | ||
| 123 | -if (elemExists('#html-editor')) { | ||
| 124 | - var tinyMceOptions = require('./pages/page-form'); | ||
| 125 | - tinymce.init(tinyMceOptions); | ||
| 126 | -} | ||
| 127 | - | ||
| 128 | // Page specific items | 126 | // Page specific items |
| 129 | -require('./pages/page-show'); | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 127 | +require('./pages/page-show'); | ... | ... |
| 1 | -module.exports = { | 1 | +var mceOptions = module.exports = { |
| 2 | selector: '#html-editor', | 2 | selector: '#html-editor', |
| 3 | content_css: [ | 3 | content_css: [ |
| 4 | '/css/styles.css' | 4 | '/css/styles.css' |
| ... | @@ -51,8 +51,13 @@ module.exports = { | ... | @@ -51,8 +51,13 @@ module.exports = { |
| 51 | args.content = ''; | 51 | args.content = ''; |
| 52 | } | 52 | } |
| 53 | }, | 53 | }, |
| 54 | + extraSetups: [], | ||
| 54 | setup: function (editor) { | 55 | setup: function (editor) { |
| 55 | 56 | ||
| 57 | + for (var i = 0; i < mceOptions.extraSetups.length; i++) { | ||
| 58 | + mceOptions.extraSetups[i](editor); | ||
| 59 | + } | ||
| 60 | + | ||
| 56 | (function () { | 61 | (function () { |
| 57 | var wrap; | 62 | var wrap; |
| 58 | 63 | ... | ... |
| ... | @@ -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; |
| ... | @@ -183,6 +189,9 @@ form.search-box { | ... | @@ -183,6 +189,9 @@ form.search-box { |
| 183 | padding-left: 0; | 189 | padding-left: 0; |
| 184 | } | 190 | } |
| 185 | } | 191 | } |
| 192 | + &.text-center { | ||
| 193 | + text-align: center; | ||
| 194 | + } | ||
| 186 | } | 195 | } |
| 187 | 196 | ||
| 188 | .setting-nav { | 197 | .setting-nav { | ... | ... |
| ... | @@ -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 | ... | ... |
| ... | @@ -8,7 +8,7 @@ | ... | @@ -8,7 +8,7 @@ |
| 8 | 8 | ||
| 9 | @section('content') | 9 | @section('content') |
| 10 | 10 | ||
| 11 | - <div class="flex-fill flex" ng-non-bindable> | 11 | + <div class="flex-fill flex"> |
| 12 | <form action="{{$book->getUrl() . '/page'}}" method="POST" class="flex flex-fill"> | 12 | <form action="{{$book->getUrl() . '/page'}}" method="POST" class="flex flex-fill"> |
| 13 | @include('pages/form') | 13 | @include('pages/form') |
| 14 | @if($chapter) | 14 | @if($chapter) | ... | ... |
| ... | @@ -8,8 +8,8 @@ | ... | @@ -8,8 +8,8 @@ |
| 8 | 8 | ||
| 9 | @section('content') | 9 | @section('content') |
| 10 | 10 | ||
| 11 | - <div class="flex-fill flex" ng-non-bindable> | 11 | + <div class="flex-fill flex"> |
| 12 | - <form action="{{$page->getUrl()}}" method="POST" class="flex flex-fill"> | 12 | + <form action="{{$page->getUrl()}}" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill"> |
| 13 | <input type="hidden" name="_method" value="PUT"> | 13 | <input type="hidden" name="_method" value="PUT"> |
| 14 | @include('pages/form', ['model' => $page]) | 14 | @include('pages/form', ['model' => $page]) |
| 15 | </form> | 15 | </form> | ... | ... |
| 1 | 1 | ||
| 2 | 2 | ||
| 3 | 3 | ||
| 4 | -<div class="page-editor flex-fill flex" ng-non-bindable> | 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,12 +9,16 @@ | ... | @@ -9,12 +9,16 @@ |
| 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-8 faded"> | 16 | + <div class="col-sm-4 faded text-center"> |
| 16 | - <div class="action-buttons"> | 17 | + <span class="faded-text" ng-bind="draftText"></span> |
| 17 | - <a href="{{ back()->getTargetUrl() }}" class="text-button text-primary"><i class="zmdi zmdi-close"></i>Cancel</a> | 18 | + </div> |
| 19 | + <div class="col-sm-4 faded"> | ||
| 20 | + <div class="action-buttons" ng-cloak> | ||
| 21 | + <button type="button" ng-if="isDraft" ng-click="discardDraft()" class="text-button text-neg"><i class="zmdi zmdi-close-circle"></i>Discard Draft</button> | ||
| 18 | <button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button> | 22 | <button type="submit" id="save-button" class="text-button text-pos"><i class="zmdi zmdi-floppy"></i>Save Page</button> |
| 19 | </div> | 23 | </div> |
| 20 | </div> | 24 | </div> |
| ... | @@ -22,13 +26,13 @@ | ... | @@ -22,13 +26,13 @@ |
| 22 | </div> | 26 | </div> |
| 23 | </div> | 27 | </div> |
| 24 | 28 | ||
| 25 | - <div class="title-input page-title clearfix"> | 29 | + <div class="title-input page-title clearfix" ng-non-bindable> |
| 26 | <div class="input"> | 30 | <div class="input"> |
| 27 | @include('form/text', ['name' => 'name', 'placeholder' => 'Page Title']) | 31 | @include('form/text', ['name' => 'name', 'placeholder' => 'Page Title']) |
| 28 | </div> | 32 | </div> |
| 29 | </div> | 33 | </div> |
| 30 | <div class="edit-area flex-fill flex"> | 34 | <div class="edit-area flex-fill flex"> |
| 31 | - <textarea id="html-editor" name="html" rows="5" | 35 | + <textarea id="html-editor" tinymce="editorOptions" mce-change="editorChange" mce-model="editorHtml" name="html" rows="5" |
| 32 | @if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea> | 36 | @if($errors->has('html')) class="neg" @endif>@if(isset($model) || old('html')){{htmlspecialchars( old('html') ? old('html') : $model->html)}}@endif</textarea> |
| 33 | @if($errors->has('html')) | 37 | @if($errors->has('html')) |
| 34 | <div class="text-neg text-small">{{ $errors->first('html') }}</div> | 38 | <div class="text-neg text-small">{{ $errors->first('html') }}</div> | ... | ... |
| ... | @@ -24,10 +24,10 @@ | ... | @@ -24,10 +24,10 @@ |
| 24 | 24 | ||
| 25 | <table class="table"> | 25 | <table class="table"> |
| 26 | <tr> | 26 | <tr> |
| 27 | - <th>Name</th> | 27 | + <th width="40%">Name</th> |
| 28 | - <th colspan="2">Created By</th> | 28 | + <th colspan="2" width="20%">Created By</th> |
| 29 | - <th>Revision Date</th> | 29 | + <th width="20%">Revision Date</th> |
| 30 | - <th>Actions</th> | 30 | + <th width="20%">Actions</th> |
| 31 | </tr> | 31 | </tr> |
| 32 | @foreach($page->revisions as $revision) | 32 | @foreach($page->revisions as $revision) |
| 33 | <tr> | 33 | <tr> |
| ... | @@ -38,7 +38,7 @@ | ... | @@ -38,7 +38,7 @@ |
| 38 | @endif | 38 | @endif |
| 39 | </td> | 39 | </td> |
| 40 | <td> @if($revision->createdBy) {{$revision->createdBy->name}} @else Deleted User @endif</td> | 40 | <td> @if($revision->createdBy) {{$revision->createdBy->name}} @else Deleted User @endif</td> |
| 41 | - <td><small>{{$revision->created_at->format('jS F, Y H:i:s')}} ({{$revision->created_at->diffForHumans()}})</small></td> | 41 | + <td><small>{{$revision->created_at->format('jS F, Y H:i:s')}} <br> ({{$revision->created_at->diffForHumans()}})</small></td> |
| 42 | <td> | 42 | <td> |
| 43 | <a href="{{$revision->getUrl()}}" target="_blank">Preview</a> | 43 | <a href="{{$revision->getUrl()}}" target="_blank">Preview</a> |
| 44 | <span class="text-muted"> | </span> | 44 | <span class="text-muted"> | </span> | ... | ... |
| 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> | ... | ... |
tests/Entity/PageUpdateDraftTest.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +class PageUpdateDraftTest extends TestCase | ||
| 5 | +{ | ||
| 6 | + protected $page; | ||
| 7 | + protected $pageRepo; | ||
| 8 | + | ||
| 9 | + public function setUp() | ||
| 10 | + { | ||
| 11 | + parent::setUp(); | ||
| 12 | + $this->page = \BookStack\Page::first(); | ||
| 13 | + $this->pageRepo = app('\BookStack\Repos\PageRepo'); | ||
| 14 | + } | ||
| 15 | + | ||
| 16 | + public function test_draft_content_shows_if_available() | ||
| 17 | + { | ||
| 18 | + $addedContent = '<p>test message content</p>'; | ||
| 19 | + $this->asAdmin()->visit($this->page->getUrl() . '/edit') | ||
| 20 | + ->dontSeeInField('html', $addedContent); | ||
| 21 | + | ||
| 22 | + $newContent = $this->page->html . $addedContent; | ||
| 23 | + $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); | ||
| 24 | + $this->asAdmin()->visit($this->page->getUrl() . '/edit') | ||
| 25 | + ->seeInField('html', $newContent); | ||
| 26 | + } | ||
| 27 | + | ||
| 28 | + public function test_draft_not_visible_by_others() | ||
| 29 | + { | ||
| 30 | + $addedContent = '<p>test message content</p>'; | ||
| 31 | + $this->asAdmin()->visit($this->page->getUrl() . '/edit') | ||
| 32 | + ->dontSeeInField('html', $addedContent); | ||
| 33 | + | ||
| 34 | + $newContent = $this->page->html . $addedContent; | ||
| 35 | + $newUser = $this->getNewUser(); | ||
| 36 | + $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); | ||
| 37 | + $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit') | ||
| 38 | + ->dontSeeInField('html', $newContent); | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + public function test_alert_message_shows_if_editing_draft() | ||
| 42 | + { | ||
| 43 | + $this->asAdmin(); | ||
| 44 | + $this->pageRepo->saveUpdateDraft($this->page, ['html' => 'test content']); | ||
| 45 | + $this->asAdmin()->visit($this->page->getUrl() . '/edit') | ||
| 46 | + ->see('You are currently editing a draft'); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + public function test_alert_message_shows_if_someone_else_editing() | ||
| 50 | + { | ||
| 51 | + $addedContent = '<p>test message content</p>'; | ||
| 52 | + $this->asAdmin()->visit($this->page->getUrl() . '/edit') | ||
| 53 | + ->dontSeeInField('html', $addedContent); | ||
| 54 | + | ||
| 55 | + $newContent = $this->page->html . $addedContent; | ||
| 56 | + $newUser = $this->getNewUser(); | ||
| 57 | + $this->pageRepo->saveUpdateDraft($this->page, ['html' => $newContent]); | ||
| 58 | + $this->actingAs($newUser)->visit($this->page->getUrl() . '/edit') | ||
| 59 | + ->see('Admin has started editing this page'); | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | +} |
-
Please register or sign in to post a comment