diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index ce2950e4d..3336e17e9 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -5,7 +5,7 @@ namespace BookStack\Activity; use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Entity; use BookStack\Facades\Activity as ActivityService; -use League\CommonMark\CommonMarkConverter; +use BookStack\Util\HtmlDescriptionFilter; class CommentRepo { @@ -20,13 +20,12 @@ class CommentRepo /** * Create a new comment on an entity. */ - public function create(Entity $entity, string $text, ?int $parent_id): Comment + public function create(Entity $entity, string $html, ?int $parent_id): Comment { $userId = user()->id; $comment = new Comment(); - $comment->text = $text; - $comment->html = $this->commentToHtml($text); + $comment->html = HtmlDescriptionFilter::filterFromString($html); $comment->created_by = $userId; $comment->updated_by = $userId; $comment->local_id = $this->getNextLocalId($entity); @@ -42,11 +41,10 @@ class CommentRepo /** * Update an existing comment. */ - public function update(Comment $comment, string $text): Comment + public function update(Comment $comment, string $html): Comment { $comment->updated_by = user()->id; - $comment->text = $text; - $comment->html = $this->commentToHtml($text); + $comment->html = HtmlDescriptionFilter::filterFromString($html); $comment->save(); ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); @@ -64,20 +62,6 @@ class CommentRepo ActivityService::add(ActivityType::COMMENT_DELETE, $comment); } - /** - * Convert the given comment Markdown to HTML. - */ - public function commentToHtml(string $commentText): string - { - $converter = new CommonMarkConverter([ - 'html_input' => 'strip', - 'max_nesting_level' => 10, - 'allow_unsafe_links' => false, - ]); - - return $converter->convert($commentText); - } - /** * Get the next local ID relative to the linked entity. */ diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index 516bcac75..340524cd0 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -22,8 +22,8 @@ class CommentController extends Controller */ public function savePageComment(Request $request, int $pageId) { - $this->validate($request, [ - 'text' => ['required', 'string'], + $input = $this->validate($request, [ + 'html' => ['required', 'string'], 'parent_id' => ['nullable', 'integer'], ]); @@ -39,7 +39,7 @@ class CommentController extends Controller // Create a new comment. $this->checkPermission('comment-create-all'); - $comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id')); + $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null); return view('comments.comment-branch', [ 'readOnly' => false, @@ -57,17 +57,20 @@ class CommentController extends Controller */ public function update(Request $request, int $commentId) { - $this->validate($request, [ - 'text' => ['required', 'string'], + $input = $this->validate($request, [ + 'html' => ['required', 'string'], ]); $comment = $this->commentRepo->getById($commentId); $this->checkOwnablePermission('page-view', $comment->entity); $this->checkOwnablePermission('comment-update', $comment); - $comment = $this->commentRepo->update($comment, $request->get('text')); + $comment = $this->commentRepo->update($comment, $input['html']); - return view('comments.comment', ['comment' => $comment, 'readOnly' => false]); + return view('comments.comment', [ + 'comment' => $comment, + 'readOnly' => false, + ]); } /** diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 6efa3df6f..7d1c54646 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -4,13 +4,14 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; use BookStack\Users\Models\HasCreatorAndUpdater; +use BookStack\Util\HtmlContentFilter; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; /** * @property int $id - * @property string $text + * @property string $text - Deprecated & now unused (#4821) * @property string $html * @property int|null $parent_id - Relates to local_id, not id * @property int $local_id @@ -24,7 +25,7 @@ class Comment extends Model implements Loggable use HasFactory; use HasCreatorAndUpdater; - protected $fillable = ['text', 'parent_id']; + protected $fillable = ['parent_id']; protected $appends = ['created', 'updated']; /** @@ -73,4 +74,9 @@ class Comment extends Model implements Loggable { return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})"; } + + public function safeHtml(): string + { + return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? ''); + } } diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php index 3303add39..16f6804ea 100644 --- a/app/Activity/Tools/CommentTree.php +++ b/app/Activity/Tools/CommentTree.php @@ -41,6 +41,17 @@ class CommentTree return $this->tree; } + public function canUpdateAny(): bool + { + foreach ($this->comments as $comment) { + if (userCan('comment-update', $comment)) { + return true; + } + } + + return false; + } + /** * @param Comment[] $comments */ diff --git a/app/Console/Commands/RegenerateCommentContentCommand.php b/app/Console/Commands/RegenerateCommentContentCommand.php deleted file mode 100644 index f7ec42620..000000000 --- a/app/Console/Commands/RegenerateCommentContentCommand.php +++ /dev/null @@ -1,49 +0,0 @@ -option('database') !== null) { - DB::setDefaultConnection($this->option('database')); - } - - Comment::query()->chunk(100, function ($comments) use ($commentRepo) { - foreach ($comments as $comment) { - $comment->html = $commentRepo->commentToHtml($comment->text); - $comment->save(); - } - }); - - DB::setDefaultConnection($connection); - $this->comment('Comment HTML content has been regenerated'); - - return 0; - } -} diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php index 4b76cd11d..efbd183b3 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -25,8 +25,8 @@ class CommentFactory extends Factory return [ 'html' => $html, - 'text' => $text, 'parent_id' => null, + 'local_id' => 1, ]; } } diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js index 8284d7f20..79c9d3c2c 100644 --- a/resources/js/components/page-comment.js +++ b/resources/js/components/page-comment.js @@ -1,5 +1,6 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; +import {buildForInput} from '../wysiwyg/config'; export class PageComment extends Component { @@ -11,7 +12,12 @@ export class PageComment extends Component { this.deletedText = this.$opts.deletedText; this.updatedText = this.$opts.updatedText; - // Element References + // Editor reference and text options + this.wysiwygEditor = null; + this.wysiwygLanguage = this.$opts.wysiwygLanguage; + this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; + + // Element references this.container = this.$el; this.contentContainer = this.$refs.contentContainer; this.form = this.$refs.form; @@ -50,8 +56,25 @@ export class PageComment extends Component { startEdit() { this.toggleEditMode(true); - const lineCount = this.$refs.input.value.split('\n').length; - this.$refs.input.style.height = `${(lineCount * 20) + 40}px`; + + if (this.wysiwygEditor) { + this.wysiwygEditor.focus(); + return; + } + + const config = buildForInput({ + language: this.wysiwygLanguage, + containerElement: this.input, + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.wysiwygTextDirection, + translations: {}, + translationMap: window.editor_translations, + }); + + window.tinymce.init(config).then(editors => { + this.wysiwygEditor = editors[0]; + setTimeout(() => this.wysiwygEditor.focus(), 50); + }); } async update(event) { @@ -60,7 +83,7 @@ export class PageComment extends Component { this.form.toggleAttribute('hidden', true); const reqData = { - text: this.input.value, + html: this.wysiwygEditor.getContent(), parent_id: this.parentId || null, }; diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index e2911afc6..cfb0634a9 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -1,5 +1,6 @@ import {Component} from './component'; import {getLoading, htmlToDom} from '../services/dom'; +import {buildForInput} from '../wysiwyg/config'; export class PageComments extends Component { @@ -21,6 +22,11 @@ export class PageComments extends Component { this.hideFormButton = this.$refs.hideFormButton; this.removeReplyToButton = this.$refs.removeReplyToButton; + // WYSIWYG options + this.wysiwygLanguage = this.$opts.wysiwygLanguage; + this.wysiwygTextDirection = this.$opts.wysiwygTextDirection; + this.wysiwygEditor = null; + // Translations this.createdText = this.$opts.createdText; this.countText = this.$opts.countText; @@ -59,9 +65,8 @@ export class PageComments extends Component { this.form.after(loading); this.form.toggleAttribute('hidden', true); - const text = this.formInput.value; const reqData = { - text, + html: this.wysiwygEditor.getContent(), parent_id: this.parentId || null, }; @@ -86,6 +91,7 @@ export class PageComments extends Component { } resetForm() { + this.removeEditor(); this.formInput.value = ''; this.parentId = null; this.replyToRow.toggleAttribute('hidden', true); @@ -93,12 +99,11 @@ export class PageComments extends Component { } showForm() { + this.removeEditor(); this.formContainer.toggleAttribute('hidden', false); this.addButtonContainer.toggleAttribute('hidden', true); this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'}); - setTimeout(() => { - this.formInput.focus(); - }, 100); + this.loadEditor(); } hideForm() { @@ -112,6 +117,34 @@ export class PageComments extends Component { this.addButtonContainer.toggleAttribute('hidden', false); } + loadEditor() { + if (this.wysiwygEditor) { + this.wysiwygEditor.focus(); + return; + } + + const config = buildForInput({ + language: this.wysiwygLanguage, + containerElement: this.formInput, + darkMode: document.documentElement.classList.contains('dark-mode'), + textDirection: this.wysiwygTextDirection, + translations: {}, + translationMap: window.editor_translations, + }); + + window.tinymce.init(config).then(editors => { + this.wysiwygEditor = editors[0]; + setTimeout(() => this.wysiwygEditor.focus(), 50); + }); + } + + removeEditor() { + if (this.wysiwygEditor) { + this.wysiwygEditor.remove(); + this.wysiwygEditor = null; + } + } + getCommentCount() { return this.container.querySelectorAll('[component="page-comment"]').length; } diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js index 88c06a334..ad964aed2 100644 --- a/resources/js/components/wysiwyg-input.js +++ b/resources/js/components/wysiwyg-input.js @@ -10,11 +10,8 @@ export class WysiwygInput extends Component { language: this.$opts.language, containerElement: this.elem, darkMode: document.documentElement.classList.contains('dark-mode'), - textDirection: this.textDirection, - translations: { - imageUploadErrorText: this.$opts.imageUploadErrorText, - serverUploadLimitText: this.$opts.serverUploadLimitText, - }, + textDirection: this.$opts.textDirection, + translations: {}, translationMap: window.editor_translations, }); diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 36d78b325..fa2df9c11 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -339,6 +339,7 @@ export function buildForInput(options) { toolbar: 'bold italic link bullist numlist', content_style: getContentStyle(options), file_picker_types: 'file', + valid_elements: 'p,a[href|title],ol,ul,li,strong,em,br', file_picker_callback: filePickerCallback, init_instance_callback(editor) { addCustomHeadContent(editor.getDoc()); diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index c4336da7c..b6a2156df 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -30,6 +30,13 @@ display: block; } +.wysiwyg-input.mce-content-body:before { + padding: 1rem; + top: 4px; + font-style: italic; + @include lightDark(color, rgba(34,47,62,.5), rgba(155,155,155,.5)) +} + // Default styles for our custom root nodes .page-content.mce-content-body doc-root { display: block; diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php index 1cb709160..b507a810b 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -1,9 +1,14 @@ +@php + $commentHtml = $comment->safeHtml(); +@endphp