]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #3387 from BookStackApp/editor_switching
authorDan Brown <redacted>
Sun, 24 Apr 2022 13:03:03 +0000 (14:03 +0100)
committerGitHub <redacted>
Sun, 24 Apr 2022 13:03:03 +0000 (14:03 +0100)
Page editor switching

42 files changed:
app/Entities/Models/Page.php
app/Entities/Models/PageRevision.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/Markdown/HtmlToMarkdown.php
app/Entities/Tools/Markdown/MarkdownToHtml.php [new file with mode: 0644]
app/Entities/Tools/PageContent.php
app/Entities/Tools/PageEditActivity.php
app/Entities/Tools/PageEditorData.php [new file with mode: 0644]
app/Http/Controllers/PageController.php
database/migrations/2022_04_17_101741_add_editor_change_field_and_permission.php [new file with mode: 0644]
resources/icons/swap-horizontal.svg [new file with mode: 0644]
resources/js/components/auto-suggest.js
resources/js/components/code-editor.js
resources/js/components/confirm-dialog.js [new file with mode: 0644]
resources/js/components/index.js
resources/js/components/page-editor.js
resources/js/components/popup.js
resources/lang/en/entities.php
resources/lang/en/settings.php
resources/sass/_components.scss
resources/sass/_lists.scss
resources/sass/_text.scss
resources/views/attachments/manager-list.blade.php
resources/views/comments/comment.blade.php
resources/views/common/confirm-dialog.blade.php [new file with mode: 0644]
resources/views/common/header.blade.php
resources/views/entities/export-menu.blade.php
resources/views/entities/sort.blade.php
resources/views/home/default.blade.php
resources/views/mfa/parts/setup-method-row.blade.php
resources/views/pages/edit.blade.php
resources/views/pages/parts/editor-toolbar.blade.php [new file with mode: 0644]
resources/views/pages/parts/form.blade.php
resources/views/pages/parts/wysiwyg-editor.blade.php
resources/views/pages/revisions.blade.php
resources/views/settings/audit.blade.php
resources/views/settings/customization.blade.php
resources/views/settings/recycle-bin/index.blade.php
resources/views/settings/roles/parts/form.blade.php
tests/Api/PagesApiTest.php
tests/Entity/PageEditorTest.php
tests/Entity/PageRevisionTest.php

index c8217af576d84af0e1aa4aa4d98526d222c471ac..ed69bcf8b8569d2e92fe94444d541872003a849b 100644 (file)
@@ -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
index 800e5e7f2d8d0332bfcc868bbdcdcb8c872d5698..be2ac33a0958fc26dddfd12ec9795f8448818a7e 100644 (file)
@@ -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'];
 
     /**
index 828c4572fd100f7355d02077477a85a8728bc216..c106d2fd30679d6f663cdd845ddbaf1b0aee4fe5 100644 (file)
@@ -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 = '';
         }
 
index 51366705ca1a1043df00a690bb5f5a70293e794a..5c7b388ea93ceda62bb8f8241ae6d924235e449c 100644 (file)
@@ -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 (file)
index 0000000..25413fb
--- /dev/null
@@ -0,0 +1,37 @@
+<?php
+
+namespace BookStack\Entities\Tools\Markdown;
+
+use BookStack\Facades\Theme;
+use BookStack\Theming\ThemeEvents;
+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 MarkdownToHtml
+{
+
+    protected string $markdown;
+
+    public function __construct(string $markdown)
+    {
+        $this->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
index b1c750adbdd6a3a3645c75e91b0f44c9e940bc6c..ea6a185f161424f3cc85d0ef918860c980bc3227 100644 (file)
@@ -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.
      */
index 9981a6ed7ae2f6fa7742729f9d01ed34ddb0af68..2672de94145d7c57a1543eb4271602ed7fb80972 100644 (file)
@@ -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 (file)
index 0000000..72f3391
--- /dev/null
@@ -0,0 +1,116 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
+use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
+
+class PageEditorData
+{
+    protected Page $page;
+    protected PageRepo $pageRepo;
+    protected string $requestedEditor;
+
+    protected array $viewData;
+    protected array $warnings;
+
+    public function __construct(Page $page, PageRepo $pageRepo, string $requestedEditor)
+    {
+        $this->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
index eecb6a6e79c94d43b1f88287f3175c8c83854639..268dce0573a9c51a0beacb264c7dc9ca14435481 100644 (file)
@@ -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());
-        $this->setPageTitle(trans('entities.pages_edit_draft'));
 
-        $draftsEnabled = $this->isSignedIn();
-        $templates = $this->pageRepo->getTemplates(10);
+        $editorData = new PageEditorData($draft, $this->pageRepo, $request->query('editor', ''));
+        $this->setPageTitle(trans('entities.pages_edit_draft'));
 
-        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 (file)
index 0000000..e71146d
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+use Carbon\Carbon;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class AddEditorChangeFieldAndPermission extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        // Add the new 'editor' column to the pages table
+        Schema::table('pages', function(Blueprint $table) {
+            $table->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 (file)
index 0000000..7bd25dd
--- /dev/null
@@ -0,0 +1 @@
+<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.99 16H14v-2H6.99v-3L3 15l3.99 4ZM21 9l-3.99-4v3H10v2h7.01v3z"/></svg>
\ No newline at end of file
index 68de49b4a32740143e7fc6fe24e442c0e502af5e..d1c15c00a7e604742d566cdd80b280fd11f82135 100644 (file)
@@ -131,7 +131,7 @@ class AutoSuggest {
             return this.hideSuggestions();
         }
 
-        this.list.innerHTML = suggestions.map(value => `<li><button type="button">${escapeHtml(value)}</button></li>`).join('');
+        this.list.innerHTML = suggestions.map(value => `<li><button type="button" class="text-item">${escapeHtml(value)}</button></li>`).join('');
         this.list.style.display = 'block';
         for (const button of this.list.querySelectorAll('button')) {
             button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
index f44de813de18bf93290e30a484a9ca3e921b26ac..4ee3531c58a7da957079a7a9ffb6c46aaaaf9c4c 100644 (file)
@@ -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 `<li><button type="button" data-time="${key}">${localTime}</button></li>`;
+             return `<li><button type="button" data-time="${key}" class="text-item">${localTime}</button></li>`;
         }).join('');
     }
 
diff --git a/resources/js/components/confirm-dialog.js b/resources/js/components/confirm-dialog.js
new file mode 100644 (file)
index 0000000..858be1b
--- /dev/null
@@ -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
index fe348aba758a157562e2d48724f6066c22301d0e..6a4a8c2b08fc9096380fc225f361e28196883150 100644 (file)
@@ -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,
index dae8071220720d6126c81f77406f88c77af5e2a0..ce123e987b055db94769f7326a8fdf63f841342b 100644 (file)
@@ -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
index 13cf69d2192c37b21b39edf8733fa7329ae22bf2..ec111963f51e65358c51d5d3333bd5d47fb8e55d 100644 (file)
@@ -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') {
index 4e4bbccd3f4227ad721134d722e20f472a518c8e..bed781b6129b180789556b43597883ba5671da21 100644 (file)
@@ -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',
index 7461c9d4ebd951748b8b84520226db894c7e1bfc..af2dcc1e1655bab7208832cb6e090a578f2e931f 100755 (executable)
@@ -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 <head> 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.',
index 95ba81520bc431eaac45a9b7dba22931126505a4..bce456cf2ce1e1b82147ae45e8f368ac83a91e83 100644 (file)
     width: 800px;
     max-width: 90%;
   }
+  &.very-small {
+    margin: 2% auto;
+    width: 600px;
+    max-width: 90%;
+  }
   &:before {
     display: flex;
     align-self: flex-start;
index 9cff52972bbcf00d7a10d3d209f37bfe696213f0..26d12a25da42949cbced533fd25ac66caac1bf9c 100644 (file)
@@ -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;
+    }
   }
 }
 
index 884808bb44daac5a9585c13302aab568a8ecf3b3..51f3156143c381271eec6e3f8b839fe6ca1daab0 100644 (file)
@@ -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 {
index b48fde9c01d90c0efe6341fcf88c7ec5d1b5d8a4..ebb1c24aadf4eca14effc513f10d8c34dc406518 100644 (file)
@@ -28,7 +28,7 @@
                             class="drag-card-action text-center text-neg">@icon('close')</button>
                     <div refs="dropdown@menu" class="dropdown-menu">
                         <p class="text-neg small px-m mb-xs">{{ trans('entities.attachments_delete') }}</p>
-                        <button refs="ajax-delete-row@delete" type="button" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                        <button refs="ajax-delete-row@delete" type="button" class="text-primary small delete text-item">{{ trans('common.confirm') }}</button>
                     </div>
                 </div>
             </div>
index 9f4a12357156cb41b65f08f61955c9d636e7d763..6189c65d41e7fab03103ff4eec1d447d8425faf4 100644 (file)
                         <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" class="text-button" title="{{ trans('common.delete') }}">@icon('delete')</button>
                         <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
                             <li class="px-m text-small text-muted pb-s">{{trans('entities.comment_delete_confirm')}}</li>
-                            <li><button action="delete" type="button" class="text-button text-neg" >@icon('delete'){{ trans('common.delete') }}</button></li>
+                            <li>
+                                <button action="delete" type="button" class="text-button text-neg icon-item">
+                                    @icon('delete')
+                                    <div>{{ trans('common.delete') }}</div>
+                                </button>
+                            </li>
                         </ul>
                     </div>
                 @endif
diff --git a/resources/views/common/confirm-dialog.blade.php b/resources/views/common/confirm-dialog.blade.php
new file mode 100644 (file)
index 0000000..28587d4
--- /dev/null
@@ -0,0 +1,21 @@
+<div components="popup confirm-dialog"
+     refs="confirm-dialog@popup {{ $ref }}"
+     class="popup-background">
+    <div class="popup-body very-small" tabindex="-1">
+
+        <div class="popup-header primary-background">
+            <div class="popup-title">{{ $title }}</div>
+            <button refs="popup@hide" type="button" class="popup-header-close">x</button>
+        </div>
+
+        <div class="px-m py-m">
+            {{ $slot }}
+
+            <div class="text-right">
+                <button type="button" class="button outline" refs="popup@hide">{{ trans('common.cancel') }}</button>
+                <button type="button" class="button" refs="confirm-dialog@confirm">{{ trans('common.continue') }}</button>
+            </div>
+        </div>
+
+    </div>
+</div>
\ No newline at end of file
index d55f3ae2dacd179a7749d362035a9ed0f2498922..b5ac520c18f37755388da0be91cd2a43cf01bc11 100644 (file)
                         </span>
                         <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
                             <li>
-                                <a href="{{ url('/favourites') }}">@icon('star'){{ trans('entities.my_favourites') }}</a>
+                                <a href="{{ url('/favourites') }}" class="icon-item">
+                                    @icon('star')
+                                    <div>{{ trans('entities.my_favourites') }}</div>
+                                </a>
                             </li>
                             <li>
-                                <a href="{{ $currentUser->getProfileUrl() }}">@icon('user'){{ trans('common.view_profile') }}</a>
+                                <a href="{{ $currentUser->getProfileUrl() }}" class="icon-item">
+                                    @icon('user')
+                                    <div>{{ trans('common.view_profile') }}</div>
+                                </a>
                             </li>
                             <li>
-                                <a href="{{ $currentUser->getEditUrl() }}">@icon('edit'){{ trans('common.edit_profile') }}</a>
+                                <a href="{{ $currentUser->getEditUrl() }}" class="icon-item">
+                                    @icon('edit')
+                                    <div>{{ trans('common.edit_profile') }}</div>
+                                </a>
                             </li>
                             <li>
                                 <form action="{{ url(config('auth.method') === 'saml2' ? '/saml2/logout' : '/logout') }}"
                                       method="post">
                                     {{ csrf_field() }}
-                                    <button class="text-muted icon-list-item text-primary">
-                                        @icon('logout'){{ trans('auth.logout') }}
+                                    <button class="icon-item">
+                                        @icon('logout')
+                                        <div>{{ trans('auth.logout') }}</div>
                                     </button>
                                 </form>
                             </li>
                             <li><hr></li>
                             <li>
-                                @include('common.dark-mode-toggle')
+                                @include('common.dark-mode-toggle', ['classes' => 'icon-item'])
                             </li>
                         </ul>
                     </div>
index 2b0f5c19dd84b9b88130afe5ee289545eae0100a..dd7231095b8f6ebf3e7e14ec9d9674adfbceb999 100644 (file)
@@ -5,9 +5,9 @@
         <span>{{ trans('entities.export') }}</span>
     </div>
     <ul refs="dropdown@menu" class="wide dropdown-menu" role="menu">
-        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" rel="noopener">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" rel="noopener">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" rel="noopener">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
-        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" rel="noopener">{{ trans('entities.export_md') }} <span class="text-muted float right">.md</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/html') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_html') }}</span><span>.html</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/pdf') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_pdf') }}</span><span>.pdf</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/plaintext') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_text') }}</span><span>.txt</span></a></li>
+        <li><a href="{{ $entity->getUrl('/export/markdown') }}" target="_blank" class="label-item"><span>{{ trans('entities.export_md') }}</span><span>.md</span></a></li>
     </ul>
 </div>
index bf90873975c7b89e483a82210ea67c263b20a79c..f81ed797f652f940a28cef225357ad751e3360a1 100644 (file)
@@ -16,7 +16,7 @@
                 <div refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" tabindex="0">{{ $options[$selectedSort] }}</div>
                 <ul refs="dropdown@menu" class="dropdown-menu">
                     @foreach($options as $key => $label)
-                        <li @if($key === $selectedSort) class="active" @endif><a href="#" data-sort-value="{{$key}}">{{ $label }}</a></li>
+                        <li @if($key === $selectedSort) class="active" @endif><a href="#" data-sort-value="{{$key}}" class="text-item">{{ $label }}</a></li>
                     @endforeach
                 </ul>
             </div>
index f6a337e5054d4bd7b978fc08c626253270d278ed..6435e4ebdc0f760801fa8c9dd6ded8bedf58f681 100644 (file)
         </div>
     </div>
 
+    <script nonce="{{ $cspNonce }}">
+        setTimeout(async () => {
+            const result = await window.components["confirm-dialog"][0].show();
+            console.log({result});
+        }, 1000);
+    </script>
+
     <div class="container" id="home-default">
         <div class="grid third gap-xxl no-row-gap" >
             <div>
index e195174c1b40c486706b4e062a183ab778e438ff..271ec1bf48d5c35762f76bdff8c643f91fce6afa 100644 (file)
@@ -19,7 +19,7 @@
                     <form action="{{ url('/mfa/' . $method . '/remove') }}" method="post">
                         {{ csrf_field() }}
                         {{ method_field('delete') }}
-                        <button class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                        <button class="text-primary small text-item">{{ trans('common.confirm') }}</button>
                     </form>
                 </div>
             </div>
index 30158e852afdb3f368ce9202991c06c64eedb7e5..cd9635758060fe77f58a99427138d81075ca9749 100644 (file)
@@ -1,9 +1,5 @@
 @extends('layouts.base')
 
-@section('head')
-    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=5.10.2') }}" nonce="{{ $cspNonce }}"></script>
-@stop
-
 @section('body-class', 'flexbox')
 
 @section('content')
@@ -12,9 +8,7 @@
         <form action="{{ $page->getUrl() }}" autocomplete="off" data-page-id="{{ $page->id }}" method="POST" class="flex flex-fill">
             {{ csrf_field() }}
 
-            @if(!isset($isDraft))
-                <input type="hidden" name="_method" value="PUT">
-            @endif
+            @if(!$isDraft) {{ method_field('PUT') }} @endif
             @include('pages.parts.form', ['model' => $page])
             @include('pages.parts.editor-toolbox')
         </form>
diff --git a/resources/views/pages/parts/editor-toolbar.blade.php b/resources/views/pages/parts/editor-toolbar.blade.php
new file mode 100644 (file)
index 0000000..9bc7947
--- /dev/null
@@ -0,0 +1,86 @@
+<div class="primary-background-light toolbar page-edit-toolbar">
+    <div class="grid third no-break v-center">
+
+        <div class="action-buttons text-left px-m py-xs">
+            <a href="{{ $isDraft ? $page->getParent()->getUrl() : $page->getUrl() }}"
+               class="text-button text-primary">@icon('back')<span class="hide-under-l">{{ trans('common.back') }}</span></a>
+        </div>
+
+        <div class="text-center px-m">
+            <div component="dropdown"
+                 option:dropdown:move-menu="true"
+                 class="dropdown-container draft-display text {{ $draftsEnabled ? '' : 'hidden' }}">
+                <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" title="{{ trans('entities.pages_edit_draft_options') }}" class="text-primary text-button py-s px-m"><span refs="page-editor@draftDisplay" class="faded-text"></span>&nbsp; @icon('more')</button>
+                @icon('check-circle', ['class' => 'text-pos draft-notification svg-icon', 'refs' => 'page-editor@draftDisplayIcon'])
+                <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
+                    <li>
+                        <button refs="page-editor@saveDraft" type="button" class="text-pos icon-item">
+                            @icon('save')
+                            <div>{{ trans('entities.pages_edit_save_draft') }}</div>
+                        </button>
+                    </li>
+                    @if($isDraft)
+                        <li>
+                            <a href="{{ $model->getUrl('/delete') }}" class="text-neg icon-item">
+                                @icon('delete')
+                                {{ trans('entities.pages_edit_delete_draft') }}
+                            </a>
+                        </li>
+                    @endif
+                    <li refs="page-editor@discardDraftWrap" class="{{ $isDraft ? '' : 'hidden' }}">
+                        <button refs="page-editor@discardDraft" type="button" class="text-neg icon-item">
+                            @icon('cancel')
+                            <div>{{ trans('entities.pages_edit_discard_draft') }}</div>
+                        </button>
+                    </li>
+                    @if(userCan('editor-change'))
+                        <li>
+                            @if($editor === 'wysiwyg')
+                                <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" class="icon-item">
+                                    @icon('swap-horizontal')
+                                    <div>
+                                        {{ trans('entities.pages_edit_switch_to_markdown') }}
+                                        <br>
+                                        <small>{{ trans('entities.pages_edit_switch_to_markdown_clean') }}</small>
+                                    </div>
+                                </a>
+                                <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-stable" refs="page-editor@changeEditor" class="icon-item">
+                                    @icon('swap-horizontal')
+                                    <div>
+                                        {{ trans('entities.pages_edit_switch_to_markdown') }}
+                                        <br>
+                                        <small>{{ trans('entities.pages_edit_switch_to_markdown_stable') }}</small>
+                                    </div>
+                                </a>
+                            @else
+                                <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=wysiwyg" refs="page-editor@changeEditor" class="icon-item">
+                                    @icon('swap-horizontal')
+                                    <div>{{ trans('entities.pages_edit_switch_to_wysiwyg') }}</div>
+                                </a>
+                            @endif
+                        </li>
+                    @endif
+                </ul>
+            </div>
+        </div>
+
+        <div class="action-buttons px-m py-xs">
+            <div component="dropdown" dropdown-move-menu class="dropdown-container">
+                <button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span refs="page-editor@changelogDisplay">{{ trans('entities.pages_edit_set_changelog') }}</span></button>
+                <ul refs="dropdown@menu" class="wide dropdown-menu">
+                    <li class="px-l py-m">
+                        <p class="text-muted pb-s">{{ trans('entities.pages_edit_enter_changelog_desc') }}</p>
+                        <input refs="page-editor@changelogInput"
+                               name="summary"
+                               id="summary-input"
+                               type="text"
+                               placeholder="{{ trans('entities.pages_edit_enter_changelog') }}" />
+                    </li>
+                </ul>
+                <span>{{-- Prevents button jumping on menu show --}}</span>
+            </div>
+
+            <button type="submit" id="save-button" class="float-left text-primary text-button text-pos-hover hide-under-m">@icon('save')<span>{{ trans('entities.pages_save') }}</span></button>
+        </div>
+    </div>
+</div>
\ No newline at end of file
index f199b86241ab00d3018c3c50739d2771b186c8fa..8da5cbf39887af42f3ca1a172e411bfdf2c4b95b 100644 (file)
@@ -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--}}
-    <div class="primary-background-light toolbar page-edit-toolbar">
-        <div class="grid third no-break v-center">
-
-            <div class="action-buttons text-left px-m py-xs">
-                <a href="{{ $page->draft ? $page->getParent()->getUrl() : $page->getUrl() }}"
-                   class="text-button text-primary">@icon('back')<span class="hide-under-l">{{ trans('common.back') }}</span></a>
-            </div>
-
-            <div class="text-center px-m py-xs">
-                <div component="dropdown"
-                     option:dropdown:move-menu="true"
-                     class="dropdown-container draft-display text {{ $draftsEnabled ? '' : 'hidden' }}">
-                    <button type="button" refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" title="{{ trans('entities.pages_edit_draft_options') }}" class="text-primary text-button"><span refs="page-editor@draftDisplay" class="faded-text"></span>&nbsp; @icon('more')</button>
-                    @icon('check-circle', ['class' => 'text-pos draft-notification svg-icon', 'refs' => 'page-editor@draftDisplayIcon'])
-                    <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
-                        <li>
-                            <button refs="page-editor@saveDraft" type="button" class="text-pos">@icon('save'){{ trans('entities.pages_edit_save_draft') }}</button>
-                        </li>
-                        @if ($model->draft)
-                        <li>
-                            <a href="{{ $model->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('entities.pages_edit_delete_draft') }}</a>
-                        </li>
-                        @endif
-                        <li refs="page-editor@discardDraftWrap" class="{{ ($model->isDraft ?? false) ? '' : 'hidden' }}">
-                            <button refs="page-editor@discardDraft" type="button" class="text-neg">@icon('cancel'){{ trans('entities.pages_edit_discard_draft') }}</button>
-                        </li>
-                    </ul>
-                </div>
-            </div>
-
-            <div class="action-buttons px-m py-xs">
-                <div component="dropdown" dropdown-move-menu class="dropdown-container">
-                    <button refs="dropdown@toggle" type="button" aria-haspopup="true" aria-expanded="false" class="text-primary text-button">@icon('edit') <span refs="page-editor@changelogDisplay">{{ trans('entities.pages_edit_set_changelog') }}</span></button>
-                    <ul refs="dropdown@menu" class="wide dropdown-menu">
-                        <li class="px-l py-m">
-                            <p class="text-muted pb-s">{{ trans('entities.pages_edit_enter_changelog_desc') }}</p>
-                            <input refs="page-editor@changelogInput"
-                                   name="summary"
-                                   id="summary-input"
-                                   type="text"
-                                   placeholder="{{ trans('entities.pages_edit_enter_changelog') }}" />
-                        </li>
-                    </ul>
-                    <span>{{-- Prevents button jumping on menu show --}}</span>
-                </div>
-
-                <button type="submit" id="save-button" class="float-left text-primary text-button text-pos-hover hide-under-m">@icon('save')<span>{{ trans('entities.pages_save') }}</span></button>
-            </div>
-        </div>
-    </div>
+    {{--Header Toolbar--}}
+    @include('pages.parts.editor-toolbar', ['model' => $model, 'editor' => $editor, 'isDraft' => $isDraft, 'draftsEnabled' => $draftsEnabled])
 
     {{--Title input--}}
     <div class="title-input page-title clearfix">
     <div class="edit-area flex-fill flex">
 
         {{--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
 
     </div>
 
+    {{--Mobile Save Button--}}
     <button type="submit"
             id="save-button-mobile"
             title="{{ trans('entities.pages_save') }}"
             class="text-primary text-button hide-over-m page-save-mobile-button">@icon('save')</button>
+
+    {{--Editor Change Dialog--}}
+    @component('common.confirm-dialog', ['title' => trans('entities.pages_editor_switch_title'), 'ref' => 'page-editor@switchDialog'])
+        <p>
+            {{ trans('entities.pages_editor_switch_are_you_sure') }}
+            <br>
+            {{ trans('entities.pages_editor_switch_consider_following') }}
+        </p>
+
+        <ul>
+            <li>{{ trans('entities.pages_editor_switch_consideration_a') }}</li>
+            <li>{{ trans('entities.pages_editor_switch_consideration_b') }}</li>
+            <li>{{ trans('entities.pages_editor_switch_consideration_c') }}</li>
+        </ul>
+    @endcomponent
 </div>
\ No newline at end of file
index 29a4b6532b596f32e3e44606d934bd4ec49e377a..d8ca7493980ec1438839effd8981d7d08fdd6173 100644 (file)
@@ -1,3 +1,7 @@
+@push('head')
+    <script src="{{ url('/libs/tinymce/tinymce.min.js?ver=5.10.2') }}" nonce="{{ $cspNonce }}"></script>
+@endpush
+
 <div component="wysiwyg-editor"
      option:wysiwyg-editor:language="{{ config('app.lang') }}"
      option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}"
index 5508f362d3fab131afd4b70e4370394c974361ec..fb9e891bbcf8c34f918cc2a03cc1621fb655ce3c 100644 (file)
 
                 <table class="table">
                     <tr>
-                        <th width="3%">{{ trans('entities.pages_revisions_number') }}</th>
-                        <th width="23%">{{ trans('entities.pages_name') }}</th>
-                        <th colspan="2" width="8%">{{ trans('entities.pages_revisions_created_by') }}</th>
-                        <th width="15%">{{ trans('entities.pages_revisions_date') }}</th>
-                        <th width="25%">{{ trans('entities.pages_revisions_changelog') }}</th>
-                        <th width="20%">{{ trans('common.actions') }}</th>
+                        <th width="40">{{ trans('entities.pages_revisions_number') }}</th>
+                        <th>
+                            {{ trans('entities.pages_name') }} / {{ trans('entities.pages_revisions_editor') }}
+                        </th>
+                        <th colspan="2">{{ trans('entities.pages_revisions_created_by') }} / {{ trans('entities.pages_revisions_date') }}</th>
+                        <th>{{ trans('entities.pages_revisions_changelog') }}</th>
+                        <th class="text-right">{{ trans('common.actions') }}</th>
                     </tr>
                     @foreach($page->revisions as $index => $revision)
                         <tr>
                             <td>{{ $revision->revision_number == 0 ? '' : $revision->revision_number }}</td>
-                            <td>{{ $revision->name }}</td>
-                            <td style="line-height: 0;">
+                            <td>
+                                {{ $revision->name }}
+                                <br>
+                                <small class="text-muted">({{ $revision->markdown ? 'Markdown' : 'WYSIWYG' }})</small>
+                            </td>
+                            <td style="line-height: 0;" width="30">
                                 @if($revision->createdBy)
                                     <img class="avatar" src="{{ $revision->createdBy->getAvatar(30) }}" alt="{{ $revision->createdBy->name }}">
                                 @endif
                             </td>
-                            <td> @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif</td>
-                            <td><small>{{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }} <br> ({{ $revision->created_at->diffForHumans() }})</small></td>
-                            <td>{{ $revision->summary }}</td>
-                            <td class="actions">
+                            <td width="260">
+                                @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif
+                                    <br>
+                                <div class="text-muted">
+                                    <small>{{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }}</small>
+                                    <small>({{ $revision->created_at->diffForHumans() }})</small>
+                                </div>
+                            </td>
+                            <td>
+                                {{ $revision->summary }}
+                            </td>
+                            <td class="actions text-small text-right">
                                 <a href="{{ $revision->getUrl('changes') }}" target="_blank" rel="noopener">{{ trans('entities.pages_revisions_changes') }}</a>
                                 <span class="text-muted">&nbsp;|&nbsp;</span>
 
                                                 <form action="{{ $revision->getUrl('/restore') }}" method="POST">
                                                     {!! csrf_field() !!}
                                                     <input type="hidden" name="_method" value="PUT">
-                                                    <button type="submit" class="text-button text-primary">@icon('history'){{ trans('entities.pages_revisions_restore') }}</button>
+                                                    <button type="submit" class="text-primary icon-item">
+                                                        @icon('history')
+                                                        <div>{{ trans('entities.pages_revisions_restore') }}</div>
+                                                    </button>
                                                 </form>
                                             </li>
                                         </ul>
                                                 <form action="{{ $revision->getUrl('/delete/') }}" method="POST">
                                                     {!! csrf_field() !!}
                                                     <input type="hidden" name="_method" value="DELETE">
-                                                    <button type="submit" class="text-button text-neg">@icon('delete'){{ trans('common.delete') }}</button>
+                                                    <button type="submit" class="text-neg icon-item">
+                                                        @icon('delete')
+                                                        <div>{{ trans('common.delete') }}</div>
+                                                    </button>
                                                 </form>
                                             </li>
                                         </ul>
index ca5dba527cb46cfe89ff191aa4d3148e122e7477..506a735a2c59552825334a2d4729bbc73831fb4d 100644 (file)
@@ -14,9 +14,9 @@
                 <label for="">{{ trans('settings.audit_event_filter') }}</label>
                 <button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
                 <ul refs="dropdown@menu" class="dropdown-menu">
-                    <li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
+                    <li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}" class="text-item">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
                     @foreach($activityTypes as $type)
-                        <li @if($type === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $type]) }}">{{ $type }}</a></li>
+                        <li @if($type === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $type]) }}" class="text-item">{{ $type }}</a></li>
                     @endforeach
                 </ul>
             </div>
index 2bc3531d75c762ecb2c5c882d6c396348349b0a0..b7be95b4a1519ac53da4b746c942300627b308a3 100644 (file)
                 </div>
             </div>
 
-            <div class="grid half gap-xl">
+            <div class="grid half gap-xl items-center">
                 <div>
-                    <label class="setting-list-label">{{ trans('settings.app_editor') }}</label>
-                    <p class="small">{{ trans('settings.app_editor_desc') }}</p>
+                    <label class="setting-list-label" for="setting-app-editor">{{ trans('settings.app_default_editor') }}</label>
+                    <p class="small">{{ trans('settings.app_default_editor_desc') }}</p>
                 </div>
-                <div class="pt-xs">
+                <div>
                     <select name="setting-app-editor" id="setting-app-editor">
                         <option @if(setting('app-editor') === 'wysiwyg') selected @endif value="wysiwyg">WYSIWYG</option>
                         <option @if(setting('app-editor') === 'markdown') selected @endif value="markdown">Markdown</option>
                 </div>
             </div>
 
-            <div homepage-control id="homepage-control" class="grid half gap-xl">
+            <div homepage-control id="homepage-control" class="grid half gap-xl items-center">
                 <div>
-                    <label for="setting-app-homepage" class="setting-list-label">{{ trans('settings.app_homepage') }}</label>
+                    <label for="setting-app-homepage-type" class="setting-list-label">{{ trans('settings.app_homepage') }}</label>
                     <p class="small">{{ trans('settings.app_homepage_desc') }}</p>
                 </div>
-                <div class="pt-xs">
+                <div>
                     <select name="setting-app-homepage-type" id="setting-app-homepage-type">
                         <option @if(setting('app-homepage-type') === 'default') selected @endif value="default">{{ trans('common.default') }}</option>
                         <option @if(setting('app-homepage-type') === 'books') selected @endif value="books">{{ trans('entities.books') }}</option>
index 5f2ec333fce479ee1e2dfcb660e0db79eb06e3b8..56e2437fe0d88987776cc8d84edcbc41553ac92c 100644 (file)
@@ -22,7 +22,7 @@
 
                             <form action="{{ url('/settings/recycle-bin/empty') }}" method="POST">
                                 {!! csrf_field() !!}
-                                <button type="submit" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+                                <button type="submit" class="text-primary small delete text-item">{{ trans('common.confirm') }}</button>
                             </form>
                         </div>
                     </div>
@@ -93,8 +93,8 @@
                         <div component="dropdown" class="dropdown-container">
                             <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
                             <ul refs="dropdown@menu" class="dropdown-menu">
-                                <li><a class="block" href="{{ $deletion->getUrl('/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
-                                <li><a class="block" href="{{ $deletion->getUrl('/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
+                                <li><a class="text-item" href="{{ $deletion->getUrl('/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
+                                <li><a class="text-item" href="{{ $deletion->getUrl('/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
                             </ul>
                         </div>
                     </td>
index a15117e5e8df0336ddb2d6eb658117a86d9c2bb3..aeaa39a6da7b5ea8cdfa574cd3e1899d98e823a8 100644 (file)
@@ -37,6 +37,7 @@
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])</div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])</div>
+                <div>@include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])</div>
             </div>
             <div>
                 <div>@include('settings.roles.parts.checkbox', ['permission' => 'settings-manage', 'label' => trans('settings.role_manage_settings')])</div>
index b91d96d892c74402e9ac79990bbcdcad663d56f0..f857db96d83af38659e85cbb71fd348516ee98b6 100644 (file)
@@ -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());
     }
index c06aa5bf125406fc0160b581d3dc5f2fe6e648e0..3fe7b33cd594a2279504a32029b521bb525f43a2 100644 (file)
@@ -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 = '<h2>A Header</h2><p>Some <strong>bold</strong> content.</p>';
+        $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 = '<h2>A Header</h2><p>Some <strong>bold</strong> content.</p>';
+        $page->save();
+
+        $resp = $this->asAdmin()->get($page->getUrl('/edit?editor=markdown-stable'));
+        $resp->assertStatus(200);
+        $resp->assertSee("<h2>A Header</h2><p>Some <strong>bold</strong> content.</p>", 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("<h2>A Header</h2>\n<p>Some content with <strong>bold</strong> text!</p>", 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 = '<h2>A Header</h2><p>Some <strong>bold</strong> content.</p>';
+        $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 = '<h2>A Header</h2><p>Some <strong>bold</strong> content.</p>';
+        $page->editor = 'wysiwyg';
+        $page->save();
+
+        $this->asEditor()->put($page->getUrl(), ['name' => $page->name, 'markdown' => '## Updated content abc']);
+        $this->assertEquals('wysiwyg', $page->refresh()->editor);
+    }
+
 }
index fc6678788636df222c405f34e58823643b4c3be2..ce203ea36d65a1805e13b4d106b2dc7dd5849d32 100644 (file)
@@ -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)');
+    }
 }