Input WYSIWYG: Added description_html field, added store logic

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.
This commit is contained in:
Dan Brown 2023-12-17 15:02:15 +00:00
parent 569542f0bb
commit c622b785a9
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
17 changed files with 167 additions and 64 deletions

View 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'],

View 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')) {

View 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());
}

View 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'];
/**

View File

@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage
{
use HasFactory;
use HasHtmlDescription;
protected $table = 'bookshelves';

View File

@ -15,6 +15,7 @@ use Illuminate\Support\Collection;
class Chapter extends BookChild
{
use HasFactory;
use HasHtmlDescription;
public $searchFactor = 1.2;

View File

@ -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);
}
}

View 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();
}
}
}

View File

@ -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);
}
};

View 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();

View 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;

View File

@ -23,6 +23,13 @@
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;

View 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">

View 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)

View 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')

View File

@ -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

View 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>
</div>
@include('entities.selector-popup', ['entityTypes' => 'page', 'selectorEndpoint' => '/search/entity-selector-templates'])
@include('form.editor-translations')