diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index c8217af57..ed69bcf8b 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -22,6 +22,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne; * @property bool $template * @property bool $draft * @property int $revision_count + * @property string $editor * @property Chapter $chapter * @property Collection $attachments * @property Collection $revisions diff --git a/app/Entities/Models/PageRevision.php b/app/Entities/Models/PageRevision.php index 800e5e7f2..be2ac33a0 100644 --- a/app/Entities/Models/PageRevision.php +++ b/app/Entities/Models/PageRevision.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * * @property mixed $id * @property int $page_id + * @property string $name * @property string $slug * @property string $book_slug * @property int $created_by @@ -21,13 +22,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string $summary * @property string $markdown * @property string $html + * @property string $text * @property int $revision_number * @property Page $page * @property-read ?User $createdBy */ class PageRevision extends Model { - protected $fillable = ['name', 'html', 'text', 'markdown', 'summary']; + protected $fillable = ['name', 'text', 'summary']; protected $hidden = ['html', 'markdown', 'restricted', 'text']; /** diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 828c4572f..c106d2fd3 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -10,6 +10,7 @@ use BookStack\Entities\Models\Page; use BookStack\Entities\Models\PageRevision; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\PageContent; +use BookStack\Entities\Tools\PageEditorData; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; @@ -217,11 +218,25 @@ class PageRepo } $pageContent = new PageContent($page); - if (!empty($input['markdown'] ?? '')) { + $currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor(); + $newEditor = $currentEditor; + + $haveInput = isset($input['markdown']) || isset($input['html']); + $inputEmpty = empty($input['markdown']) && empty($input['html']); + + if ($haveInput && $inputEmpty) { + $pageContent->setNewHTML(''); + } elseif (!empty($input['markdown']) && is_string($input['markdown'])) { + $newEditor = 'markdown'; $pageContent->setNewMarkdown($input['markdown']); } elseif (isset($input['html'])) { + $newEditor = 'wysiwyg'; $pageContent->setNewHTML($input['html']); } + + if ($newEditor !== $currentEditor && userCan('editor-change')) { + $page->editor = $newEditor; + } } /** @@ -229,8 +244,12 @@ class PageRepo */ protected function savePageRevision(Page $page, string $summary = null): PageRevision { - $revision = new PageRevision($page->getAttributes()); + $revision = new PageRevision(); + $revision->name = $page->name; + $revision->html = $page->html; + $revision->markdown = $page->markdown; + $revision->text = $page->text; $revision->page_id = $page->id; $revision->slug = $page->slug; $revision->book_slug = $page->book->slug; @@ -260,10 +279,15 @@ class PageRepo return $page; } - // Otherwise save the data to a revision + // Otherwise, save the data to a revision $draft = $this->getPageRevisionToUpdate($page); $draft->fill($input); - if (setting('app-editor') !== 'markdown') { + + if (!empty($input['markdown'])) { + $draft->markdown = $input['markdown']; + $draft->html = ''; + } else { + $draft->html = $input['html']; $draft->markdown = ''; } diff --git a/app/Entities/Tools/Markdown/HtmlToMarkdown.php b/app/Entities/Tools/Markdown/HtmlToMarkdown.php index 51366705c..5c7b388ea 100644 --- a/app/Entities/Tools/Markdown/HtmlToMarkdown.php +++ b/app/Entities/Tools/Markdown/HtmlToMarkdown.php @@ -21,7 +21,7 @@ use League\HTMLToMarkdown\HtmlConverter; class HtmlToMarkdown { - protected $html; + protected string $html; public function __construct(string $html) { diff --git a/app/Entities/Tools/Markdown/MarkdownToHtml.php b/app/Entities/Tools/Markdown/MarkdownToHtml.php new file mode 100644 index 000000000..25413fb33 --- /dev/null +++ b/app/Entities/Tools/Markdown/MarkdownToHtml.php @@ -0,0 +1,37 @@ +markdown = $markdown; + } + + public function convert(): string + { + $environment = Environment::createCommonMarkEnvironment(); + $environment->addExtension(new TableExtension()); + $environment->addExtension(new TaskListExtension()); + $environment->addExtension(new CustomStrikeThroughExtension()); + $environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment; + $converter = new CommonMarkConverter([], $environment); + + $environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10); + + return $converter->convertToHtml($this->markdown); + } + +} \ No newline at end of file diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index b1c750adb..ea6a185f1 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -3,11 +3,8 @@ namespace BookStack\Entities\Tools; use BookStack\Entities\Models\Page; -use BookStack\Entities\Tools\Markdown\CustomListItemRenderer; -use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension; +use BookStack\Entities\Tools\Markdown\MarkdownToHtml; use BookStack\Exceptions\ImageUploadException; -use BookStack\Facades\Theme; -use BookStack\Theming\ThemeEvents; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageService; use BookStack\Util\HtmlContentFilter; @@ -17,15 +14,10 @@ use DOMNode; use DOMNodeList; use DOMXPath; use Illuminate\Support\Str; -use League\CommonMark\Block\Element\ListItem; -use League\CommonMark\CommonMarkConverter; -use League\CommonMark\Environment; -use League\CommonMark\Extension\Table\TableExtension; -use League\CommonMark\Extension\TaskList\TaskListExtension; class PageContent { - protected $page; + protected Page $page; /** * PageContent constructor. @@ -53,28 +45,11 @@ class PageContent { $markdown = $this->extractBase64ImagesFromMarkdown($markdown); $this->page->markdown = $markdown; - $html = $this->markdownToHtml($markdown); + $html = (new MarkdownToHtml($markdown))->convert(); $this->page->html = $this->formatHtml($html); $this->page->text = $this->toPlainText(); } - /** - * Convert the given Markdown content to a HTML string. - */ - protected function markdownToHtml(string $markdown): string - { - $environment = Environment::createCommonMarkEnvironment(); - $environment->addExtension(new TableExtension()); - $environment->addExtension(new TaskListExtension()); - $environment->addExtension(new CustomStrikeThroughExtension()); - $environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment; - $converter = new CommonMarkConverter([], $environment); - - $environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10); - - return $converter->convertToHtml($markdown); - } - /** * Convert all base64 image data to saved images. */ diff --git a/app/Entities/Tools/PageEditActivity.php b/app/Entities/Tools/PageEditActivity.php index 9981a6ed7..2672de941 100644 --- a/app/Entities/Tools/PageEditActivity.php +++ b/app/Entities/Tools/PageEditActivity.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Builder; class PageEditActivity { - protected $page; + protected Page $page; /** * PageEditActivity constructor. diff --git a/app/Entities/Tools/PageEditorData.php b/app/Entities/Tools/PageEditorData.php new file mode 100644 index 000000000..72f3391ea --- /dev/null +++ b/app/Entities/Tools/PageEditorData.php @@ -0,0 +1,116 @@ +page = $page; + $this->pageRepo = $pageRepo; + $this->requestedEditor = $requestedEditor; + + $this->viewData = $this->build(); + } + + public function getViewData(): array + { + return $this->viewData; + } + + public function getWarnings(): array + { + return $this->warnings; + } + + protected function build(): array + { + $page = clone $this->page; + $isDraft = boolval($this->page->draft); + $templates = $this->pageRepo->getTemplates(10); + $draftsEnabled = auth()->check(); + + $isDraftRevision = false; + $this->warnings = []; + $editActivity = new PageEditActivity($page); + + if ($editActivity->hasActiveEditing()) { + $this->warnings[] = $editActivity->activeEditingMessage(); + } + + // Check for a current draft version for this user + $userDraft = $this->pageRepo->getUserDraft($page); + if ($userDraft !== null) { + $page->forceFill($userDraft->only(['name', 'html', 'markdown'])); + $isDraftRevision = true; + $this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft); + } + + $editorType = $this->getEditorType($page); + $this->updateContentForEditor($page, $editorType); + + return [ + 'page' => $page, + 'book' => $page->book, + 'isDraft' => $isDraft, + 'isDraftRevision' => $isDraftRevision, + 'draftsEnabled' => $draftsEnabled, + 'templates' => $templates, + 'editor' => $editorType, + ]; + } + + protected function updateContentForEditor(Page $page, string $editorType): void + { + $isHtml = !empty($page->html) && empty($page->markdown); + + // HTML to markdown-clean conversion + if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') { + $page->markdown = (new HtmlToMarkdown($page->html))->convert(); + } + + // Markdown to HTML conversion if we don't have HTML + if ($editorType === 'wysiwyg' && !$isHtml) { + $page->html = (new MarkdownToHtml($page->markdown))->convert(); + } + } + + /** + * Get the type of editor to show for editing the given page. + * Defaults based upon the current content of the page otherwise will fall back + * to system default but will take a requested type (if provided) if permissions allow. + */ + protected function getEditorType(Page $page): string + { + $editorType = $page->editor ?: self::getSystemDefaultEditor(); + + // Use requested editor if valid and if we have permission + $requestedType = explode('-', $this->requestedEditor)[0]; + if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) { + $editorType = $requestedType; + } + + return $editorType; + } + + /** + * Get the configured system default editor. + */ + public static function getSystemDefaultEditor(): string + { + return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg'; + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index eecb6a6e7..268dce057 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -10,6 +10,7 @@ use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\NextPreviousContentLocator; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditActivity; +use BookStack\Entities\Tools\PageEditorData; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\PermissionsException; @@ -21,7 +22,7 @@ use Throwable; class PageController extends Controller { - protected $pageRepo; + protected PageRepo $pageRepo; /** * PageController constructor. @@ -82,22 +83,15 @@ class PageController extends Controller * * @throws NotFoundException */ - public function editDraft(string $bookSlug, int $pageId) + public function editDraft(Request $request, string $bookSlug, int $pageId) { $draft = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-create', $draft->getParent()); + + $editorData = new PageEditorData($draft, $this->pageRepo, $request->query('editor', '')); $this->setPageTitle(trans('entities.pages_edit_draft')); - $draftsEnabled = $this->isSignedIn(); - $templates = $this->pageRepo->getTemplates(10); - - return view('pages.edit', [ - 'page' => $draft, - 'book' => $draft->book, - 'isDraft' => true, - 'draftsEnabled' => $draftsEnabled, - 'templates' => $templates, - ]); + return view('pages.edit', $editorData->getViewData()); } /** @@ -188,43 +182,19 @@ class PageController extends Controller * * @throws NotFoundException */ - public function edit(string $bookSlug, string $pageSlug) + public function edit(Request $request, string $bookSlug, string $pageSlug) { $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-update', $page); - $page->isDraft = false; - $editActivity = new PageEditActivity($page); - - // Check for active editing - $warnings = []; - if ($editActivity->hasActiveEditing()) { - $warnings[] = $editActivity->activeEditingMessage(); + $editorData = new PageEditorData($page, $this->pageRepo, $request->query('editor', '')); + if ($editorData->getWarnings()) { + $this->showWarningNotification(implode("\n", $editorData->getWarnings())); } - // Check for a current draft version for this user - $userDraft = $this->pageRepo->getUserDraft($page); - if ($userDraft !== null) { - $page->forceFill($userDraft->only(['name', 'html', 'markdown'])); - $page->isDraft = true; - $warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft); - } - - if (count($warnings) > 0) { - $this->showWarningNotification(implode("\n", $warnings)); - } - - $templates = $this->pageRepo->getTemplates(10); - $draftsEnabled = $this->isSignedIn(); $this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()])); - return view('pages.edit', [ - 'page' => $page, - 'book' => $page->book, - 'current' => $page, - 'draftsEnabled' => $draftsEnabled, - 'templates' => $templates, - ]); + return view('pages.edit', $editorData->getViewData()); } /** diff --git a/database/migrations/2022_04_17_101741_add_editor_change_field_and_permission.php b/database/migrations/2022_04_17_101741_add_editor_change_field_and_permission.php new file mode 100644 index 000000000..e71146dbd --- /dev/null +++ b/database/migrations/2022_04_17_101741_add_editor_change_field_and_permission.php @@ -0,0 +1,62 @@ +string('editor', 50)->default(''); + }); + + // Populate the new 'editor' column + // We set it to 'markdown' for pages currently with markdown content + DB::table('pages')->where('markdown', '!=', '')->update(['editor' => 'markdown']); + // We set it to 'wysiwyg' where we have HTML but no markdown + DB::table('pages')->where('markdown', '=', '') + ->where('html', '!=', '') + ->update(['editor' => 'wysiwyg']); + + // Give the admin user permission to change the editor + $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id; + + $permissionId = DB::table('role_permissions')->insertGetId([ + 'name' => 'editor-change', + 'display_name' => 'Change page editor', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString(), + ]); + + DB::table('permission_role')->insert([ + 'role_id' => $adminRoleId, + 'permission_id' => $permissionId, + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Drop the new column from the pages table + Schema::table('pages', function(Blueprint $table) { + $table->dropColumn('editor'); + }); + + // Remove traces of the role permission + DB::table('role_permissions')->where('name', '=', 'editor-change')->delete(); + } +} diff --git a/resources/icons/swap-horizontal.svg b/resources/icons/swap-horizontal.svg new file mode 100644 index 000000000..7bd25dd7e --- /dev/null +++ b/resources/icons/swap-horizontal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js index 68de49b4a..d1c15c00a 100644 --- a/resources/js/components/auto-suggest.js +++ b/resources/js/components/auto-suggest.js @@ -131,7 +131,7 @@ class AutoSuggest { return this.hideSuggestions(); } - this.list.innerHTML = suggestions.map(value => `
  • `).join(''); + this.list.innerHTML = suggestions.map(value => `
  • `).join(''); this.list.style.display = 'block'; for (const button of this.list.querySelectorAll('button')) { button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this)); diff --git a/resources/js/components/code-editor.js b/resources/js/components/code-editor.js index f44de813d..4ee3531c5 100644 --- a/resources/js/components/code-editor.js +++ b/resources/js/components/code-editor.js @@ -96,7 +96,7 @@ class CodeEditor { this.historyDropDown.classList.toggle('hidden', historyKeys.length === 0); this.historyList.innerHTML = historyKeys.map(key => { const localTime = (new Date(parseInt(key))).toLocaleTimeString(); - return `
  • `; + return `
  • `; }).join(''); } diff --git a/resources/js/components/confirm-dialog.js b/resources/js/components/confirm-dialog.js new file mode 100644 index 000000000..858be1b85 --- /dev/null +++ b/resources/js/components/confirm-dialog.js @@ -0,0 +1,52 @@ +import {onSelect} from "../services/dom"; + +/** + * Custom equivalent of window.confirm() using our popup component. + * Is promise based so can be used like so: + * `const result = await dialog.show()` + * @extends {Component} + */ +class ConfirmDialog { + + setup() { + this.container = this.$el; + this.confirmButton = this.$refs.confirm; + + this.res = null; + + onSelect(this.confirmButton, () => { + this.sendResult(true); + this.getPopup().hide(); + }); + } + + show() { + this.getPopup().show(null, () => { + this.sendResult(false); + }); + + return new Promise((res, rej) => { + this.res = res; + }); + } + + /** + * @returns {Popup} + */ + getPopup() { + return this.container.components.popup; + } + + /** + * @param {Boolean} result + */ + sendResult(result) { + if (this.res) { + this.res(result) + this.res = null; + } + } + +} + +export default ConfirmDialog; \ No newline at end of file diff --git a/resources/js/components/index.js b/resources/js/components/index.js index fe348aba7..6a4a8c2b0 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -10,6 +10,7 @@ import chapterToggle from "./chapter-toggle.js" import codeEditor from "./code-editor.js" import codeHighlighter from "./code-highlighter.js" import collapsible from "./collapsible.js" +import confirmDialog from "./confirm-dialog" import customCheckbox from "./custom-checkbox.js" import detailsHighlighter from "./details-highlighter.js" import dropdown from "./dropdown.js" @@ -26,7 +27,6 @@ import headerMobileToggle from "./header-mobile-toggle.js" import homepageControl from "./homepage-control.js" import imageManager from "./image-manager.js" import imagePicker from "./image-picker.js" -import index from "./index.js" import listSortControl from "./list-sort-control.js" import markdownEditor from "./markdown-editor.js" import newUserPassword from "./new-user-password.js" @@ -66,6 +66,7 @@ const componentMapping = { "code-editor": codeEditor, "code-highlighter": codeHighlighter, "collapsible": collapsible, + "confirm-dialog": confirmDialog, "custom-checkbox": customCheckbox, "details-highlighter": detailsHighlighter, "dropdown": dropdown, @@ -82,7 +83,6 @@ const componentMapping = { "homepage-control": homepageControl, "image-manager": imageManager, "image-picker": imagePicker, - "index": index, "list-sort-control": listSortControl, "markdown-editor": markdownEditor, "new-user-password": newUserPassword, diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index dae807122..ce123e987 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -24,6 +24,8 @@ class PageEditor { this.draftDisplayIcon = this.$refs.draftDisplayIcon; this.changelogInput = this.$refs.changelogInput; this.changelogDisplay = this.$refs.changelogDisplay; + this.changeEditorButtons = this.$manyRefs.changeEditor; + this.switchDialogContainer = this.$refs.switchDialog; // Translations this.draftText = this.$opts.draftText; @@ -72,6 +74,9 @@ class PageEditor { // Draft Controls onSelect(this.saveDraftButton, this.saveDraft.bind(this)); onSelect(this.discardDraftButton, this.discardDraft.bind(this)); + + // Change editor controls + onSelect(this.changeEditorButtons, this.changeEditor.bind(this)); } setInitialFocus() { @@ -113,17 +118,21 @@ class PageEditor { data.markdown = this.editorMarkdown; } + let didSave = false; try { const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data); if (!this.isNewDraft) { this.toggleDiscardDraftVisibility(true); } + this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`); this.autoSave.last = Date.now(); if (resp.data.warning && !this.shownWarningsCache.has(resp.data.warning)) { window.$events.emit('warning', resp.data.warning); this.shownWarningsCache.add(resp.data.warning); } + + didSave = true; } catch (err) { // Save the editor content in LocalStorage as a last resort, just in case. try { @@ -134,6 +143,7 @@ class PageEditor { window.$events.emit('error', this.autosaveFailText); } + return didSave; } draftNotifyChange(text) { @@ -185,6 +195,18 @@ class PageEditor { this.discardDraftWrap.classList.toggle('hidden', !show); } + async changeEditor(event) { + event.preventDefault(); + + const link = event.target.closest('a').href; + const dialog = this.switchDialogContainer.components['confirm-dialog']; + const [saved, confirmed] = await Promise.all([this.saveDraft(), dialog.show()]); + + if (saved && confirmed) { + window.location = link; + } + } + } export default PageEditor; \ No newline at end of file diff --git a/resources/js/components/popup.js b/resources/js/components/popup.js index 13cf69d21..ec111963f 100644 --- a/resources/js/components/popup.js +++ b/resources/js/components/popup.js @@ -34,7 +34,7 @@ class Popup { } hide(onComplete = null) { - fadeOut(this.container, 240, onComplete); + fadeOut(this.container, 120, onComplete); if (this.onkeyup) { window.removeEventListener('keyup', this.onkeyup); this.onkeyup = null; @@ -45,7 +45,7 @@ class Popup { } show(onComplete = null, onHide = null) { - fadeIn(this.container, 240, onComplete); + fadeIn(this.container, 120, onComplete); this.onkeyup = (event) => { if (event.key === 'Escape') { diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 4e4bbccd3..bed781b61 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -196,9 +196,19 @@ return [ 'pages_edit_draft_save_at' => 'Draft saved at ', 'pages_edit_delete_draft' => 'Delete Draft', 'pages_edit_discard_draft' => 'Discard Draft', + 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', + 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', + 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', + 'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', 'pages_edit_set_changelog' => 'Set Changelog', 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', 'pages_edit_enter_changelog' => 'Enter Changelog', + 'pages_editor_switch_title' => 'Switch Editor', + 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', + 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', + 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', + 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', + 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', 'pages_save' => 'Save Page', 'pages_title' => 'Page Title', 'pages_name' => 'Page Name', @@ -225,6 +235,7 @@ return [ 'pages_revisions_number' => '#', 'pages_revisions_numbered' => 'Revision #:id', 'pages_revisions_numbered_changes' => 'Revision #:id Changes', + 'pages_revisions_editor' => 'Editor Type', 'pages_revisions_changelog' => 'Changelog', 'pages_revisions_changes' => 'Changes', 'pages_revisions_current' => 'Current Version', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 7461c9d4e..af2dcc1e1 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -27,8 +27,8 @@ return [ 'app_secure_images' => 'Higher Security Image Uploads', 'app_secure_images_toggle' => 'Enable higher security image uploads', 'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.', - 'app_editor' => 'Page Editor', - 'app_editor_desc' => 'Select which editor will be used by all users to edit pages.', + 'app_default_editor' => 'Default Page Editor', + 'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.', 'app_custom_html' => 'Custom HTML Head Content', 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', @@ -152,6 +152,7 @@ return [ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_editor_change' => 'Change page editor', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 95ba81520..bce456cf2 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -120,6 +120,11 @@ width: 800px; max-width: 90%; } + &.very-small { + margin: 2% auto; + width: 600px; + max-width: 90%; + } &:before { display: flex; align-self: flex-start; diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 9cff52972..26d12a25d 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -593,13 +593,22 @@ ul.pagination { li.active a { font-weight: 600; } - a, button { - display: block; - padding: $-xs $-m; + button { + width: 100%; + text-align: start; + } + li.border-bottom { + border-bottom: 1px solid #DDD; + } + li hr { + margin: $-xs 0; + } + .icon-item, .text-item, .label-item { + padding: 8px $-m; @include lightDark(color, #555, #eee); fill: currentColor; white-space: nowrap; - line-height: 1.6; + line-height: 1.4; cursor: pointer; &:hover, &:focus { text-decoration: none; @@ -616,15 +625,30 @@ ul.pagination { width: 16px; } } - button { - width: 100%; - text-align: start; + .text-item { + display: block; } - li.border-bottom { - border-bottom: 1px solid #DDD; + .label-item { + display: grid; + align-items: center; + grid-template-columns: auto min-content; + gap: $-m; } - li hr { - margin: $-xs 0; + .label-item > *:nth-child(2) { + opacity: 0.7; + &:hover { + opacity: 1; + } + } + .icon-item { + display: grid; + align-items: start; + grid-template-columns: 16px auto; + gap: $-m; + svg { + margin-inline-end: 0; + margin-block-start: 1px; + } } } diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index 884808bb4..51f315614 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -163,7 +163,6 @@ em, i, .italic { small, p.small, span.small, .text-small { font-size: 0.75rem; - @include lightDark(color, #5e5e5e, #999); } sup, .superscript { diff --git a/resources/views/attachments/manager-list.blade.php b/resources/views/attachments/manager-list.blade.php index b48fde9c0..ebb1c24aa 100644 --- a/resources/views/attachments/manager-list.blade.php +++ b/resources/views/attachments/manager-list.blade.php @@ -28,7 +28,7 @@ class="drag-card-action text-center text-neg">@icon('close') diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 9f4a12357..6189c65d4 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -31,7 +31,12 @@ @endif diff --git a/resources/views/common/confirm-dialog.blade.php b/resources/views/common/confirm-dialog.blade.php new file mode 100644 index 000000000..28587d4e8 --- /dev/null +++ b/resources/views/common/confirm-dialog.blade.php @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index d55f3ae2d..b5ac520c1 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -62,26 +62,36 @@ diff --git a/resources/views/entities/export-menu.blade.php b/resources/views/entities/export-menu.blade.php index 2b0f5c19d..dd7231095 100644 --- a/resources/views/entities/export-menu.blade.php +++ b/resources/views/entities/export-menu.blade.php @@ -5,9 +5,9 @@ {{ trans('entities.export') }} diff --git a/resources/views/entities/sort.blade.php b/resources/views/entities/sort.blade.php index bf9087397..f81ed797f 100644 --- a/resources/views/entities/sort.blade.php +++ b/resources/views/entities/sort.blade.php @@ -16,7 +16,7 @@ diff --git a/resources/views/home/default.blade.php b/resources/views/home/default.blade.php index f6a337e50..6435e4ebd 100644 --- a/resources/views/home/default.blade.php +++ b/resources/views/home/default.blade.php @@ -17,6 +17,13 @@ + +
    diff --git a/resources/views/mfa/parts/setup-method-row.blade.php b/resources/views/mfa/parts/setup-method-row.blade.php index e195174c1..271ec1bf4 100644 --- a/resources/views/mfa/parts/setup-method-row.blade.php +++ b/resources/views/mfa/parts/setup-method-row.blade.php @@ -19,7 +19,7 @@
    {{ csrf_field() }} {{ method_field('delete') }} - +
    diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index 30158e852..cd9635758 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -1,9 +1,5 @@ @extends('layouts.base') -@section('head') - -@stop - @section('body-class', 'flexbox') @section('content') @@ -12,9 +8,7 @@
    {{ csrf_field() }} - @if(!isset($isDraft)) - - @endif + @if(!$isDraft) {{ method_field('PUT') }} @endif @include('pages.parts.form', ['model' => $page]) @include('pages.parts.editor-toolbox')
    diff --git a/resources/views/pages/parts/editor-toolbar.blade.php b/resources/views/pages/parts/editor-toolbar.blade.php new file mode 100644 index 000000000..9bc79476e --- /dev/null +++ b/resources/views/pages/parts/editor-toolbar.blade.php @@ -0,0 +1,86 @@ +
    +
    + + + +
    + +
    + +
    + + + +
    +
    +
    \ No newline at end of file diff --git a/resources/views/pages/parts/form.blade.php b/resources/views/pages/parts/form.blade.php index f199b8624..8da5cbf39 100644 --- a/resources/views/pages/parts/form.blade.php +++ b/resources/views/pages/parts/form.blade.php @@ -6,66 +6,17 @@ @if($model->name === trans('entities.pages_initial_name')) option:page-editor:has-default-title="true" @endif - option:page-editor:editor-type="{{ setting('app-editor') }}" + option:page-editor:editor-type="{{ $editor }}" option:page-editor:page-id="{{ $model->id ?? '0' }}" - option:page-editor:page-new-draft="{{ ($model->draft ?? false) ? 'true' : 'false' }}" - option:page-editor:draft-text="{{ ($model->draft || $model->isDraft) ? trans('entities.pages_editing_draft') : trans('entities.pages_editing_page') }}" + option:page-editor:page-new-draft="{{ $isDraft ? 'true' : 'false' }}" + option:page-editor:draft-text="{{ ($isDraft || $isDraftRevision) ? trans('entities.pages_editing_draft') : trans('entities.pages_editing_page') }}" option:page-editor:autosave-fail-text="{{ trans('errors.page_draft_autosave_fail') }}" option:page-editor:editing-page-text="{{ trans('entities.pages_editing_page') }}" option:page-editor:draft-discarded-text="{{ trans('entities.pages_draft_discarded') }}" option:page-editor:set-changelog-text="{{ trans('entities.pages_edit_set_changelog') }}"> - {{--Header Bar--}} -
    -
    - - - -
    - -
    - -
    - - - -
    -
    -
    + {{--Header Toolbar--}} + @include('pages.parts.editor-toolbar', ['model' => $model, 'editor' => $editor, 'isDraft' => $isDraft, 'draftsEnabled' => $draftsEnabled]) {{--Title input--}}
    @@ -78,19 +29,35 @@
    {{--WYSIWYG Editor--}} - @if(setting('app-editor') === 'wysiwyg') + @if($editor === 'wysiwyg') @include('pages.parts.wysiwyg-editor', ['model' => $model]) @endif {{--Markdown Editor--}} - @if(setting('app-editor') === 'markdown') + @if($editor === 'markdown') @include('pages.parts.markdown-editor', ['model' => $model]) @endif
    + {{--Mobile Save Button--}} + + {{--Editor Change Dialog--}} + @component('common.confirm-dialog', ['title' => trans('entities.pages_editor_switch_title'), 'ref' => 'page-editor@switchDialog']) +

    + {{ trans('entities.pages_editor_switch_are_you_sure') }} +
    + {{ trans('entities.pages_editor_switch_consider_following') }} +

    + + + @endcomponent
    \ No newline at end of file diff --git a/resources/views/pages/parts/wysiwyg-editor.blade.php b/resources/views/pages/parts/wysiwyg-editor.blade.php index 29a4b6532..d8ca74939 100644 --- a/resources/views/pages/parts/wysiwyg-editor.blade.php +++ b/resources/views/pages/parts/wysiwyg-editor.blade.php @@ -1,3 +1,7 @@ +@push('head') + +@endpush +
    - {{ trans('entities.pages_revisions_number') }} - {{ trans('entities.pages_name') }} - {{ trans('entities.pages_revisions_created_by') }} - {{ trans('entities.pages_revisions_date') }} - {{ trans('entities.pages_revisions_changelog') }} - {{ trans('common.actions') }} + {{ trans('entities.pages_revisions_number') }} + + {{ trans('entities.pages_name') }} / {{ trans('entities.pages_revisions_editor') }} + + {{ trans('entities.pages_revisions_created_by') }} / {{ trans('entities.pages_revisions_date') }} + {{ trans('entities.pages_revisions_changelog') }} + {{ trans('common.actions') }} @foreach($page->revisions as $index => $revision) {{ $revision->revision_number == 0 ? '' : $revision->revision_number }} - {{ $revision->name }} - + + {{ $revision->name }} +
    + ({{ $revision->markdown ? 'Markdown' : 'WYSIWYG' }}) + + @if($revision->createdBy) {{ $revision->createdBy->name }} @endif - @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif - {{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }}
    ({{ $revision->created_at->diffForHumans() }})
    - {{ $revision->summary }} - + + @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif +
    +
    + {{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }} + ({{ $revision->created_at->diffForHumans() }}) +
    + + + {{ $revision->summary }} + + {{ trans('entities.pages_revisions_changes') }}  |  @@ -58,7 +71,10 @@
    {!! csrf_field() !!} - +
    @@ -72,7 +88,10 @@
    {!! csrf_field() !!} - +
    diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index ca5dba527..506a735a2 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -14,9 +14,9 @@
    diff --git a/resources/views/settings/customization.blade.php b/resources/views/settings/customization.blade.php index 2bc3531d7..b7be95b4a 100644 --- a/resources/views/settings/customization.blade.php +++ b/resources/views/settings/customization.blade.php @@ -23,12 +23,12 @@
    -
    +
    - -

    {{ trans('settings.app_editor_desc') }}

    + +

    {{ trans('settings.app_default_editor_desc') }}

    -
    +
    diff --git a/resources/views/settings/recycle-bin/index.blade.php b/resources/views/settings/recycle-bin/index.blade.php index 5f2ec333f..56e2437fe 100644 --- a/resources/views/settings/recycle-bin/index.blade.php +++ b/resources/views/settings/recycle-bin/index.blade.php @@ -22,7 +22,7 @@
    {!! csrf_field() !!} - +
    @@ -93,8 +93,8 @@ diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index a15117e5e..aeaa39a6d 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -37,6 +37,7 @@
    @include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
    @include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
    @include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])
    @include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])
    diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index b91d96d89..f857db96d 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -252,7 +252,9 @@ class PagesApiTest extends TestCase 'tags' => [['name' => 'Category', 'value' => 'Testing']] ]; - $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details); + $resp->assertOk(); + $page->refresh(); $this->assertGreaterThan(Carbon::now()->subDay()->unix(), $page->updated_at->unix()); } diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index c06aa5bf1..3fe7b33cd 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -18,36 +18,39 @@ class PageEditorTest extends TestCase $this->page = Page::query()->first(); } - public function test_default_editor_is_wysiwyg() + public function test_default_editor_is_wysiwyg_for_new_pages() { $this->assertEquals('wysiwyg', setting('app-editor')); - $this->asAdmin()->get($this->page->getUrl() . '/edit') - ->assertElementExists('#html-editor'); + $resp = $this->asAdmin()->get($this->page->book->getUrl('/create-page')); + $this->followRedirects($resp)->assertElementExists('#html-editor'); } - public function test_markdown_setting_shows_markdown_editor() + public function test_markdown_setting_shows_markdown_editor_for_new_pages() { $this->setSettings(['app-editor' => 'markdown']); - $this->asAdmin()->get($this->page->getUrl() . '/edit') + + $resp = $this->asAdmin()->get($this->page->book->getUrl('/create-page')); + $this->followRedirects($resp) ->assertElementNotExists('#html-editor') ->assertElementExists('#markdown-editor'); } public function test_markdown_content_given_to_editor() { - $this->setSettings(['app-editor' => 'markdown']); - $mdContent = '# hello. This is a test'; $this->page->markdown = $mdContent; + $this->page->editor = 'markdown'; $this->page->save(); - $this->asAdmin()->get($this->page->getUrl() . '/edit') + $this->asAdmin()->get($this->page->getUrl('/edit')) ->assertElementContains('[name="markdown"]', $mdContent); } public function test_html_content_given_to_editor_if_no_markdown() { - $this->setSettings(['app-editor' => 'markdown']); + $this->page->editor = 'markdown'; + $this->page->save(); + $this->asAdmin()->get($this->page->getUrl() . '/edit') ->assertElementContains('[name="markdown"]', $this->page->html); } @@ -102,4 +105,102 @@ class PageEditorTest extends TestCase $resp = $this->get($draft->getUrl('/edit')); $resp->assertElementContains('a[href="' . $draft->getUrl() . '"]', 'Back'); } + + public function test_switching_from_html_to_clean_markdown_works() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = '

    A Header

    Some bold content.

    '; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=markdown-clean')); + $resp->assertStatus(200); + $resp->assertSee("## A Header\n\nSome **bold** content."); + $resp->assertElementExists('#markdown-editor'); + } + + public function test_switching_from_html_to_stable_markdown_works() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = '

    A Header

    Some bold content.

    '; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=markdown-stable')); + $resp->assertStatus(200); + $resp->assertSee("

    A Header

    Some bold content.

    ", true); + $resp->assertElementExists('[component="markdown-editor"]'); + } + + public function test_switching_from_markdown_to_wysiwyg_works() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = ''; + $page->markdown = "## A Header\n\nSome content with **bold** text!"; + $page->save(); + + $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=wysiwyg')); + $resp->assertStatus(200); + $resp->assertElementExists('[component="wysiwyg-editor"]'); + $resp->assertSee("

    A Header

    \n

    Some content with bold text!

    ", true); + } + + public function test_page_editor_changes_with_editor_property() + { + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $resp->assertElementExists('[component="wysiwyg-editor"]'); + + $this->page->markdown = "## A Header\n\nSome content with **bold** text!"; + $this->page->editor = 'markdown'; + $this->page->save(); + + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $resp->assertElementExists('[component="markdown-editor"]'); + } + + public function test_editor_type_switch_options_show() + { + $resp = $this->asAdmin()->get($this->page->getUrl('/edit')); + $editLink = $this->page->getUrl('/edit') . '?editor='; + $resp->assertElementContains("a[href=\"${editLink}markdown-clean\"]", '(Clean Content)'); + $resp->assertElementContains("a[href=\"${editLink}markdown-stable\"]", '(Stable Content)'); + + $resp = $this->asAdmin()->get($this->page->getUrl('/edit?editor=markdown-stable')); + $editLink = $this->page->getUrl('/edit') . '?editor='; + $resp->assertElementContains("a[href=\"${editLink}wysiwyg\"]", 'Switch to WYSIWYG Editor'); + } + + public function test_editor_type_switch_options_dont_show_if_without_change_editor_permissions() + { + $resp = $this->asEditor()->get($this->page->getUrl('/edit')); + $editLink = $this->page->getUrl('/edit') . '?editor='; + $resp->assertElementNotExists("a[href*=\"${editLink}\"]"); + } + + public function test_page_editor_type_switch_does_not_work_without_change_editor_permissions() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = '

    A Header

    Some bold content.

    '; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/edit?editor=markdown-stable')); + $resp->assertStatus(200); + $resp->assertElementExists('[component="wysiwyg-editor"]'); + $resp->assertElementNotExists('[component="markdown-editor"]'); + } + + public function test_page_save_does_not_change_active_editor_without_change_editor_permissions() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->html = '

    A Header

    Some bold content.

    '; + $page->editor = 'wysiwyg'; + $page->save(); + + $this->asEditor()->put($page->getUrl(), ['name' => $page->name, 'markdown' => '## Updated content abc']); + $this->assertEquals('wysiwyg', $page->refresh()->editor); + } + } diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index fc6678788..ce203ea36 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -203,4 +203,19 @@ class PageRevisionTest extends TestCase $revisionCount = $page->revisions()->count(); $this->assertEquals(12, $revisionCount); } + + public function test_revision_list_shows_editor_type() + { + /** @var Page $page */ + $page = Page::first(); + $this->asAdmin()->put($page->getUrl(), ['name' => 'Updated page', 'html' => 'new page html']); + + $resp = $this->get($page->refresh()->getUrl('/revisions')); + $resp->assertElementContains('td', '(WYSIWYG)'); + $resp->assertElementNotContains('td', '(Markdown)'); + + $this->asAdmin()->put($page->getUrl(), ['name' => 'Updated page', 'markdown' => '# Some markdown content']); + $resp = $this->get($page->refresh()->getUrl('/revisions')); + $resp->assertElementContains('td', '(Markdown)'); + } }