]> BookStack Code Mirror - bookstack/commitdiff
Input WYSIWYG: Added description_html field, added store logic
authorDan Brown <redacted>
Sun, 17 Dec 2023 15:02:15 +0000 (15:02 +0000)
committerDan Brown <redacted>
Sun, 17 Dec 2023 15:02:15 +0000 (15:02 +0000)
Rolled out HTML editor field and store logic across all target entity
types. Cleaned up WYSIWYG input logic and design.
Cleaned up some injected classes while there.

17 files changed:
app/Entities/Controllers/BookController.php
app/Entities/Controllers/BookshelfController.php
app/Entities/Controllers/ChapterController.php
app/Entities/Models/Book.php
app/Entities/Models/Bookshelf.php
app/Entities/Models/Chapter.php
app/Entities/Models/HasHtmlDescription.php [new file with mode: 0644]
app/Entities/Repos/BaseRepo.php
database/migrations/2023_12_17_140913_add_description_html_to_entities.php [new file with mode: 0644]
resources/js/wysiwyg/config.js
resources/sass/_forms.scss
resources/sass/_tinymce.scss
resources/views/books/parts/form.blade.php
resources/views/books/show.blade.php
resources/views/chapters/parts/form.blade.php
resources/views/form/description-html-input.blade.php [new file with mode: 0644]
resources/views/shelves/parts/form.blade.php

index faa5788938e979eab0946d440608e0417fc3fee4..481c621e6df5eef7adcd530443dfaae3781c8d16 100644 (file)
@@ -93,7 +93,7 @@ class BookController extends Controller
         $this->checkPermission('book-create-all');
         $validated = $this->validate($request, [
             'name'                => ['required', 'string', 'max:255'],
-            'description'         => ['string', 'max:1000'],
+            'description_html'    => ['string', 'max:2000'],
             'image'               => array_merge(['nullable'], $this->getImageValidationRules()),
             'tags'                => ['array'],
             'default_template_id' => ['nullable', 'integer'],
@@ -168,7 +168,7 @@ class BookController extends Controller
 
         $validated = $this->validate($request, [
             'name'                => ['required', 'string', 'max:255'],
-            'description'         => ['string', 'max:1000'],
+            'description_html'    => ['string', 'max:2000'],
             'image'               => array_merge(['nullable'], $this->getImageValidationRules()),
             'tags'                => ['array'],
             'default_template_id' => ['nullable', 'integer'],
index fcfd37538724a8c653e9997e3df732011cd30243..acc972348247de566522891fb4475a63d2c90e5e 100644 (file)
@@ -18,15 +18,11 @@ use Illuminate\Validation\ValidationException;
 
 class BookshelfController extends Controller
 {
-    protected BookshelfRepo $shelfRepo;
-    protected ShelfContext $shelfContext;
-    protected ReferenceFetcher $referenceFetcher;
-
-    public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
-    {
-        $this->shelfRepo = $shelfRepo;
-        $this->shelfContext = $shelfContext;
-        $this->referenceFetcher = $referenceFetcher;
+    public function __construct(
+        protected BookshelfRepo $shelfRepo,
+        protected ShelfContext $shelfContext,
+        protected ReferenceFetcher $referenceFetcher
+    ) {
     }
 
     /**
@@ -81,10 +77,10 @@ class BookshelfController extends Controller
     {
         $this->checkPermission('bookshelf-create-all');
         $validated = $this->validate($request, [
-            'name'        => ['required', 'string', 'max:255'],
-            'description' => ['string', 'max:1000'],
-            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
-            'tags'        => ['array'],
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'image'            => array_merge(['nullable'], $this->getImageValidationRules()),
+            'tags'             => ['array'],
         ]);
 
         $bookIds = explode(',', $request->get('books', ''));
@@ -164,10 +160,10 @@ class BookshelfController extends Controller
         $shelf = $this->shelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-update', $shelf);
         $validated = $this->validate($request, [
-            'name'        => ['required', 'string', 'max:255'],
-            'description' => ['string', 'max:1000'],
-            'image'       => array_merge(['nullable'], $this->getImageValidationRules()),
-            'tags'        => ['array'],
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'image'            => array_merge(['nullable'], $this->getImageValidationRules()),
+            'tags'             => ['array'],
         ]);
 
         if ($request->has('image_reset')) {
index 40a5373031733aa0b4bf1acfbbe95053afe01e72..73f314ab6db0419aee1c33aa50e699a2d502115e 100644 (file)
@@ -22,13 +22,10 @@ use Throwable;
 
 class ChapterController extends Controller
 {
-    protected ChapterRepo $chapterRepo;
-    protected ReferenceFetcher $referenceFetcher;
-
-    public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
-    {
-        $this->chapterRepo = $chapterRepo;
-        $this->referenceFetcher = $referenceFetcher;
+    public function __construct(
+        protected ChapterRepo $chapterRepo,
+        protected ReferenceFetcher $referenceFetcher
+    ) {
     }
 
     /**
@@ -51,14 +48,16 @@ class ChapterController extends Controller
      */
     public function store(Request $request, string $bookSlug)
     {
-        $this->validate($request, [
-            'name' => ['required', 'string', 'max:255'],
+        $validated = $this->validate($request, [
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'tags'             => ['array'],
         ]);
 
         $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
         $this->checkOwnablePermission('chapter-create', $book);
 
-        $chapter = $this->chapterRepo->create($request->all(), $book);
+        $chapter = $this->chapterRepo->create($validated, $book);
 
         return redirect($chapter->getUrl());
     }
@@ -111,10 +110,16 @@ class ChapterController extends Controller
      */
     public function update(Request $request, string $bookSlug, string $chapterSlug)
     {
+        $validated = $this->validate($request, [
+            'name'             => ['required', 'string', 'max:255'],
+            'description_html' => ['string', 'max:2000'],
+            'tags'             => ['array'],
+        ]);
+
         $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('chapter-update', $chapter);
 
-        $this->chapterRepo->update($chapter, $request->all());
+        $this->chapterRepo->update($chapter, $validated);
 
         return redirect($chapter->getUrl());
     }
index ee9a7f44722538cf3a421add4e347d33612dd7c9..7bbe2d8a40ea66e4b3f706c556df0f6cb3abea16 100644 (file)
@@ -26,10 +26,11 @@ use Illuminate\Support\Collection;
 class Book extends Entity implements HasCoverImage
 {
     use HasFactory;
+    use HasHtmlDescription;
 
     public $searchFactor = 1.2;
 
-    protected $fillable = ['name', 'description'];
+    protected $fillable = ['name'];
     protected $hidden = ['pivot', 'image_id', 'deleted_at'];
 
     /**
index 4b44025a4c3e84eb3b00838cb4c32868e19d3b13..cf22195f759f656739660e5c89aab6fcd7f08354 100644 (file)
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 class Bookshelf extends Entity implements HasCoverImage
 {
     use HasFactory;
+    use HasHtmlDescription;
 
     protected $table = 'bookshelves';
 
index 98889ce3f38a430c15d281d64b20b05491fc9bb9..17fccfd6cd59e646ca450a181ab4d6578d1df98e 100644 (file)
@@ -15,6 +15,7 @@ use Illuminate\Support\Collection;
 class Chapter extends BookChild
 {
     use HasFactory;
+    use HasHtmlDescription;
 
     public $searchFactor = 1.2;
 
diff --git a/app/Entities/Models/HasHtmlDescription.php b/app/Entities/Models/HasHtmlDescription.php
new file mode 100644 (file)
index 0000000..cc431f7
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\Util\HtmlContentFilter;
+
+/**
+ * @property string $description
+ * @property string $description_html
+ */
+trait HasHtmlDescription
+{
+    /**
+     * Get the HTML description for this book.
+     */
+    public function descriptionHtml(): string
+    {
+        $html = $this->description_html ?: '<p>' . e($this->description) . '</p>';
+        return HtmlContentFilter::removeScriptsFromHtmlString($html);
+    }
+}
index 2894a04e36ee6388ea29c41ef133a74466288003..f6b9ff57821b900a15103cd280ca04afe16448cd 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos;
 use BookStack\Activity\TagRepo;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\HasCoverImage;
+use BookStack\Entities\Models\HasHtmlDescription;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\References\ReferenceUpdater;
 use BookStack\Uploads\ImageRepo;
@@ -12,15 +13,11 @@ use Illuminate\Http\UploadedFile;
 
 class BaseRepo
 {
-    protected TagRepo $tagRepo;
-    protected ImageRepo $imageRepo;
-    protected ReferenceUpdater $referenceUpdater;
-
-    public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
-    {
-        $this->tagRepo = $tagRepo;
-        $this->imageRepo = $imageRepo;
-        $this->referenceUpdater = $referenceUpdater;
+    public function __construct(
+        protected TagRepo $tagRepo,
+        protected ImageRepo $imageRepo,
+        protected ReferenceUpdater $referenceUpdater
+    ) {
     }
 
     /**
@@ -29,6 +26,7 @@ class BaseRepo
     public function create(Entity $entity, array $input)
     {
         $entity->fill($input);
+        $this->updateDescription($entity, $input);
         $entity->forceFill([
             'created_by' => user()->id,
             'updated_by' => user()->id,
@@ -54,6 +52,7 @@ class BaseRepo
         $oldUrl = $entity->getUrl();
 
         $entity->fill($input);
+        $this->updateDescription($entity, $input);
         $entity->updated_by = user()->id;
 
         if ($entity->isDirty('name') || empty($entity->slug)) {
@@ -99,4 +98,20 @@ class BaseRepo
             $entity->save();
         }
     }
+
+    protected function updateDescription(Entity $entity, array $input): void
+    {
+        if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
+            return;
+        }
+
+        /** @var HasHtmlDescription $entity */
+        if (isset($input['description_html'])) {
+            $entity->description_html = $input['description_html'];
+            $entity->description = html_entity_decode(strip_tags($input['description_html']));
+        } else if (isset($input['description'])) {
+            $entity->description = $input['description'];
+            $entity->description_html = $entity->descriptionHtml();
+        }
+    }
 }
diff --git a/database/migrations/2023_12_17_140913_add_description_html_to_entities.php b/database/migrations/2023_12_17_140913_add_description_html_to_entities.php
new file mode 100644 (file)
index 0000000..68c52e8
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        $addColumn = fn(Blueprint $table) => $table->text('description_html');
+
+        Schema::table('books', $addColumn);
+        Schema::table('chapters', $addColumn);
+        Schema::table('bookshelves', $addColumn);
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        $removeColumn = fn(Blueprint $table) => $table->removeColumn('description_html');
+
+        Schema::table('books', $removeColumn);
+        Schema::table('chapters', $removeColumn);
+        Schema::table('bookshelves', $removeColumn);
+    }
+};
index d7c6bba729559047daa432d3ddadf22308b83cbc..f0a2dbe1ceb3e66231306ce71d1c43d1816d43bd 100644 (file)
@@ -304,7 +304,7 @@ export function buildForInput(options) {
     // Return config object
     return {
         width: '100%',
-        height: '300px',
+        height: '185px',
         target: options.containerElement,
         cache_suffix: `?version=${version}`,
         content_css: [
@@ -312,7 +312,7 @@ export function buildForInput(options) {
         ],
         branding: false,
         skin: options.darkMode ? 'tinymce-5-dark' : 'tinymce-5',
-        body_class: 'page-content',
+        body_class: 'wysiwyg-input',
         browser_spellcheck: true,
         relative_urls: false,
         language: options.language,
@@ -323,11 +323,13 @@ export function buildForInput(options) {
         remove_trailing_brs: false,
         statusbar: false,
         menubar: false,
-        plugins: 'link autolink',
+        plugins: 'link autolink lists',
         contextmenu: false,
-        toolbar: 'bold italic underline link',
+        toolbar: 'bold italic underline link bullist numlist',
         content_style: getContentStyle(options),
         color_map: colorMap,
+        file_picker_types: 'file',
+        file_picker_callback: filePickerCallback,
         init_instance_callback(editor) {
             const head = editor.getDoc().querySelector('head');
             head.innerHTML += fetchCustomHeadContent();
index cd5d929f4b57835e5e8da3629d2988645f2ed5e9..b63f9cdd51d0c1e670b94359dd2732317701a294 100644 (file)
@@ -406,6 +406,14 @@ input[type=color] {
   height: auto;
 }
 
+.description-input > .tox-tinymce {
+  border: 1px solid #DDD !important;
+  border-radius: 3px;
+  .tox-toolbar__primary {
+    justify-content: end;
+  }
+}
+
 .search-box {
   max-width: 100%;
   position: relative;
index 8e036fc462069c95834b4f095fa8824694a38fd2..c4336da7cb7efd02cdd3e96f171b8fe84181aa7f 100644 (file)
   display: block;
 }
 
+.wysiwyg-input.mce-content-body {
+  padding-block-start: 1rem;
+  padding-block-end: 1rem;
+  outline: 0;
+  display: block;
+}
+
 // Default styles for our custom root nodes
 .page-content.mce-content-body doc-root {
   display: block;
index 3a2e30da60d930326715f46a100b29a6ad99341d..d380c5871c8b38c630b9b4e575ba32876f971617 100644 (file)
@@ -9,17 +9,8 @@
 </div>
 
 <div class="form-group description-input">
-    <label for="description">{{ trans('common.description') }}</label>
-    @include('form.textarea', ['name' => 'description'])
-
-    <textarea component="wysiwyg-input"
-              option:wysiwyg-input:language="{{ $locale->htmlLang() }}"
-              option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}"
-              id="description_html" name="description_html" rows="5"
-              @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ? old($name) : $model->description_html}}@endif</textarea>
-    @if($errors->has('description_html'))
-        <div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
-    @endif
+    <label for="description_html">{{ trans('common.description') }}</label>
+    @include('form.description-html-input')
 </div>
 
 <div class="form-group collapsible" component="collapsible" id="logo-control">
index 8f7c3f6cf1133c08546be55dcce3e962774cea06..5884e41fde8f978f982b884e9022214f7352077b 100644 (file)
@@ -26,7 +26,7 @@
     <main class="content-wrap card">
         <h1 class="break-text">{{$book->name}}</h1>
         <div refs="entity-search@contentView" class="book-content">
-            <p class="text-muted">{!! nl2br(e($book->description)) !!}</p>
+            <p class="text-muted">{!! $book->descriptionHtml() !!}</p>
             @if(count($bookChildren) > 0)
                 <div class="entity-list book-contents">
                     @foreach($bookChildren as $childElement)
index 8abcebe133aef9bb520f3033b72067ef2b7d223d..7c565f43cf59bb850aa4d89dccadbe962d50853a 100644 (file)
@@ -1,14 +1,16 @@
+@push('head')
+    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
+@endpush
 
-{!! csrf_field() !!}
-
+{{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
     @include('form.text', ['name' => 'name', 'autofocus' => true])
 </div>
 
 <div class="form-group description-input">
-    <label for="description">{{ trans('common.description') }}</label>
-    @include('form.textarea', ['name' => 'description'])
+    <label for="description_html">{{ trans('common.description') }}</label>
+    @include('form.description-html-input')
 </div>
 
 <div class="form-group collapsible" component="collapsible" id="logo-control">
@@ -24,3 +26,6 @@
     <a href="{{ isset($chapter) ? $chapter->getUrl() : $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
     <button type="submit" class="button">{{ trans('entities.chapters_save') }}</button>
 </div>
+
+@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates'])
+@include('form.editor-translations')
\ No newline at end of file
diff --git a/resources/views/form/description-html-input.blade.php b/resources/views/form/description-html-input.blade.php
new file mode 100644 (file)
index 0000000..3cf726b
--- /dev/null
@@ -0,0 +1,8 @@
+<textarea component="wysiwyg-input"
+          option:wysiwyg-input:language="{{ $locale->htmlLang() }}"
+          option:wysiwyg-input:text-direction="{{ $locale->htmlDirection() }}"
+          id="description_html" name="description_html" rows="5"
+          @if($errors->has('description_html')) class="text-neg" @endif>@if(isset($model) || old('description_html')){{ old('description_html') ?? $model->descriptionHtml()}}@endif</textarea>
+@if($errors->has('description_html'))
+    <div class="text-neg text-small">{{ $errors->first('description_html') }}</div>
+@endif
\ No newline at end of file
index ad67cb85ce80dbe73fb870d83201b8fe56bebfa5..a724c99ce751dea451b2750377fc51b2cf32be2d 100644 (file)
@@ -1,13 +1,16 @@
-{{ csrf_field() }}
+@push('head')
+    <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
+@endpush
 
+{{ csrf_field() }}
 <div class="form-group title-input">
     <label for="name">{{ trans('common.name') }}</label>
     @include('form.text', ['name' => 'name', 'autofocus' => true])
 </div>
 
 <div class="form-group description-input">
-    <label for="description">{{ trans('common.description') }}</label>
-    @include('form.textarea', ['name' => 'description'])
+    <label for="description_html">{{ trans('common.description') }}</label>
+    @include('form.description-html-input')
 </div>
 
 <div component="shelf-sort" class="grid half gap-xl">
@@ -84,4 +87,7 @@
 <div class="form-group text-right">
     <a href="{{ isset($shelf) ? $shelf->getUrl() : url('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a>
     <button type="submit" class="button">{{ trans('entities.shelves_save') }}</button>
-</div>
\ No newline at end of file
+</div>
+
+@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates'])
+@include('form.editor-translations')
\ No newline at end of file