From e173d9b4dd430313d3340e3758aa85879fd8e924 Mon Sep 17 00:00:00 2001 From: manukminasyan Date: Fri, 27 Mar 2026 18:43:07 +0400 Subject: [PATCH] refactor: replace custom textarea with Filament RichEditor and built-in mentions Replace the custom Alpine.js textarea + mention system with Filament v5's built-in RichEditor component and MentionProvider. This fixes Alpine scope errors (showMentions/mentionResults not defined) that occurred during Livewire DOM morphing inside Filament slide-over modals. - Add InteractsWithForms + HasForms to Comments and CommentItem components - Define commentForm(), editForm(), replyForm() with RichEditor + mentions - Add CommentsConfig::makeMentionProvider() shared helper - Update MentionParser to extract mention IDs from RichEditor HTML format - Update Comment::renderBodyWithMentions() to use RichContentRenderer - Remove all custom Alpine.js mention code from blade templates - Backward compatible with existing plain text comments --- .../views/livewire/comment-item.blade.php | 103 +----------------- resources/views/livewire/comments.blade.php | 95 +--------------- src/CommentsConfig.php | 16 +++ src/Livewire/CommentItem.php | 92 +++++++++------- src/Livewire/Comments.php | 60 +++++----- src/Mentions/MentionParser.php | 26 +++++ src/Models/Comment.php | 20 ++++ 7 files changed, 154 insertions(+), 258 deletions(-) diff --git a/resources/views/livewire/comment-item.blade.php b/resources/views/livewire/comment-item.blade.php index 90a7551..b65e940 100644 --- a/resources/views/livewire/comment-item.blade.php +++ b/resources/views/livewire/comment-item.blade.php @@ -33,14 +33,7 @@ {{-- Body or edit form --}} @if ($isEditing)
- - - - @error('editBody') -

{{ $message }}

- @enderror + {{ $this->editForm }}
@@ -111,98 +104,8 @@ {{-- Reply form --}} @if ($isReplying) - - - - - - {{-- Mention autocomplete dropdown --}} -
- -
- - @error('replyBody') -

{{ $message }}

- @enderror + + {{ $this->replyForm }} @if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
diff --git a/resources/views/livewire/comments.blade.php b/resources/views/livewire/comments.blade.php index 02731f6..2b3e4c7 100644 --- a/resources/views/livewire/comments.blade.php +++ b/resources/views/livewire/comments.blade.php @@ -60,99 +60,8 @@ {{-- New comment form - only for authorized users --}} @auth @can('create', \Relaticle\Comments\CommentsConfig::getCommentModel()) - - - - - - {{-- Mention autocomplete dropdown --}} -
- -
- - @error('newComment') -

{{ $message }}

- @enderror + + {{ $this->commentForm }} @if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
diff --git a/src/CommentsConfig.php b/src/CommentsConfig.php index 4c64446..a6f2c33 100644 --- a/src/CommentsConfig.php +++ b/src/CommentsConfig.php @@ -4,6 +4,7 @@ namespace Relaticle\Comments; use App\Models\User; use Closure; +use Filament\Forms\Components\RichEditor\MentionProvider; use Relaticle\Comments\Mentions\DefaultMentionResolver; use Relaticle\Comments\Models\Comment; use Relaticle\Comments\Policies\CommentPolicy; @@ -173,4 +174,19 @@ class CommentsConfig { static::$resolveAuthenticatedUser = $callback; } + + public static function makeMentionProvider(): MentionProvider + { + return MentionProvider::make('@') + ->getSearchResultsUsing(fn (string $search): array => static::getCommenterModel()::query() + ->where('name', 'like', "%{$search}%") + ->orderBy('name') + ->limit(static::getMentionMaxResults()) + ->pluck('name', 'id') + ->all()) + ->getLabelsUsing(fn (array $ids): array => static::getCommenterModel()::query() + ->whereIn('id', $ids) + ->pluck('name', 'id') + ->all()); + } } diff --git a/src/Livewire/CommentItem.php b/src/Livewire/CommentItem.php index b862e86..ead7671 100644 --- a/src/Livewire/CommentItem.php +++ b/src/Livewire/CommentItem.php @@ -2,20 +2,24 @@ namespace Relaticle\Comments\Livewire; +use Filament\Forms\Components\RichEditor; +use Filament\Forms\Concerns\InteractsWithForms; +use Filament\Forms\Contracts\HasForms; +use Filament\Schemas\Schema; use Illuminate\Contracts\View\View; use Livewire\Component; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; use Relaticle\Comments\CommentsConfig; -use Relaticle\Comments\Contracts\MentionResolver; use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Events\CommentDeleted; use Relaticle\Comments\Events\CommentUpdated; use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Models\Comment; -class CommentItem extends Component +class CommentItem extends Component implements HasForms { + use InteractsWithForms; use WithFileUploads; public Comment $comment; @@ -24,9 +28,11 @@ class CommentItem extends Component public bool $isReplying = false; - public string $editBody = ''; + /** @var array */ + public ?array $editData = []; - public string $replyBody = ''; + /** @var array */ + public ?array $replyData = []; /** @var array */ public array $replyAttachments = []; @@ -36,30 +42,60 @@ class CommentItem extends Component $this->comment = $comment; } + public function editForm(Schema $schema): Schema + { + return $schema + ->components([ + RichEditor::make('body') + ->hiddenLabel() + ->required() + ->placeholder(__('Edit your comment...')) + ->toolbarButtons(CommentsConfig::getEditorToolbar()) + ->mentions([ + CommentsConfig::makeMentionProvider(), + ]), + ]) + ->statePath('editData'); + } + + public function replyForm(Schema $schema): Schema + { + return $schema + ->components([ + RichEditor::make('body') + ->hiddenLabel() + ->required() + ->placeholder(__('Write a reply...')) + ->toolbarButtons(CommentsConfig::getEditorToolbar()) + ->mentions([ + CommentsConfig::makeMentionProvider(), + ]), + ]) + ->statePath('replyData'); + } + public function startEdit(): void { $this->authorize('update', $this->comment); $this->isEditing = true; - $this->editBody = $this->comment->body; + $this->editForm->fill(['body' => $this->comment->body]); } public function cancelEdit(): void { $this->isEditing = false; - $this->editBody = ''; + $this->editForm->fill(); } public function saveEdit(): void { $this->authorize('update', $this->comment); - $this->validate([ - 'editBody' => ['required', 'string', 'min:1'], - ]); + $data = $this->editForm->getState(); $this->comment->update([ - 'body' => $this->editBody, + 'body' => $data['body'] ?? '', 'edited_at' => now(), ]); @@ -70,7 +106,7 @@ class CommentItem extends Component $this->dispatch('commentUpdated'); $this->isEditing = false; - $this->editBody = ''; + $this->editForm->fill(); } public function deleteComment(): void @@ -91,12 +127,13 @@ class CommentItem extends Component } $this->isReplying = true; + $this->replyForm->fill(); } public function cancelReply(): void { $this->isReplying = false; - $this->replyBody = ''; + $this->replyForm->fill(); $this->replyAttachments = []; } @@ -104,20 +141,20 @@ class CommentItem extends Component { $this->authorize('reply', $this->comment); - $rules = ['replyBody' => ['required', 'string', 'min:1']]; + $data = $this->replyForm->getState(); if (CommentsConfig::areAttachmentsEnabled()) { $maxSize = CommentsConfig::getAttachmentMaxSize(); $allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes()); - $rules['replyAttachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"]; + $this->validate([ + 'replyAttachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"], + ]); } - $this->validate($rules); - $user = CommentsConfig::resolveAuthenticatedUser(); $reply = $this->comment->commentable->comments()->create([ - 'body' => $this->replyBody, + 'body' => $data['body'] ?? '', 'parent_id' => $this->comment->id, 'commenter_id' => $user->getKey(), 'commenter_type' => $user->getMorphClass(), @@ -146,7 +183,7 @@ class CommentItem extends Component $this->dispatch('commentUpdated'); $this->isReplying = false; - $this->replyBody = ''; + $this->replyForm->fill(); $this->replyAttachments = []; } @@ -157,25 +194,6 @@ class CommentItem extends Component $this->replyAttachments = array_values($attachments); } - /** @return array */ - public function searchUsers(string $query): array - { - if (mb_strlen($query) < 1) { - return []; - } - - $resolver = app(MentionResolver::class); - - return $resolver->search($query) - ->map(fn ($user) => [ - 'id' => $user->getKey(), - 'name' => $user->getCommentDisplayName(), - 'avatar_url' => $user->getCommentAvatarUrl(), - ]) - ->values() - ->all(); - } - public function render(): View { return view('comments::livewire.comment-item'); diff --git a/src/Livewire/Comments.php b/src/Livewire/Comments.php index 49156b5..bfbe6bc 100644 --- a/src/Livewire/Comments.php +++ b/src/Livewire/Comments.php @@ -2,6 +2,10 @@ namespace Relaticle\Comments\Livewire; +use Filament\Forms\Components\RichEditor; +use Filament\Forms\Concerns\InteractsWithForms; +use Filament\Forms\Contracts\HasForms; +use Filament\Schemas\Schema; use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -10,19 +14,20 @@ use Livewire\Component; use Livewire\Features\SupportFileUploads\TemporaryUploadedFile; use Livewire\WithFileUploads; use Relaticle\Comments\CommentsConfig; -use Relaticle\Comments\Contracts\MentionResolver; use Relaticle\Comments\Events\CommentCreated; use Relaticle\Comments\Mentions\MentionParser; use Relaticle\Comments\Models\Comment; use Relaticle\Comments\Models\Subscription; -class Comments extends Component +class Comments extends Component implements HasForms { + use InteractsWithForms; use WithFileUploads; public Model $model; - public string $newComment = ''; + /** @var array */ + public ?array $commentData = []; public string $sortDirection = 'asc'; @@ -38,6 +43,23 @@ class Comments extends Component $this->model = $model; $this->perPage = CommentsConfig::getPerPage(); $this->loadedCount = $this->perPage; + $this->commentForm->fill(); + } + + public function commentForm(Schema $schema): Schema + { + return $schema + ->components([ + RichEditor::make('body') + ->hiddenLabel() + ->required() + ->placeholder(__('Write a comment...')) + ->toolbarButtons(CommentsConfig::getEditorToolbar()) + ->mentions([ + CommentsConfig::makeMentionProvider(), + ]), + ]) + ->statePath('commentData'); } /** @return Collection */ @@ -95,22 +117,22 @@ class Comments extends Component public function addComment(): void { - $rules = ['newComment' => ['required', 'string', 'min:1']]; + $data = $this->commentForm->getState(); if (CommentsConfig::areAttachmentsEnabled()) { $maxSize = CommentsConfig::getAttachmentMaxSize(); $allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes()); - $rules['attachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"]; + $this->validate([ + 'attachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"], + ]); } - $this->validate($rules); - $this->authorize('create', CommentsConfig::getCommentModel()); $user = CommentsConfig::resolveAuthenticatedUser(); $comment = $this->model->comments()->create([ - 'body' => $this->newComment, + 'body' => $data['body'] ?? '', 'commenter_id' => $user->getKey(), 'commenter_type' => $user->getMorphClass(), ]); @@ -135,7 +157,8 @@ class Comments extends Component app(MentionParser::class)->syncMentions($comment); - $this->reset('newComment', 'attachments'); + $this->commentForm->fill(); + $this->reset('attachments'); } public function removeAttachment(int $index): void @@ -183,25 +206,6 @@ class Comments extends Component unset($this->comments, $this->totalCount, $this->hasMore); } - /** @return array */ - public function searchUsers(string $query): array - { - if (mb_strlen($query) < 1) { - return []; - } - - $resolver = app(MentionResolver::class); - - return $resolver->search($query) - ->map(fn ($user) => [ - 'id' => $user->getKey(), - 'name' => $user->getCommentDisplayName(), - 'avatar_url' => $user->getCommentAvatarUrl(), - ]) - ->values() - ->all(); - } - public function render(): View { return view('comments::livewire.comments'); diff --git a/src/Mentions/MentionParser.php b/src/Mentions/MentionParser.php index 1cf5bdc..f21eb89 100644 --- a/src/Mentions/MentionParser.php +++ b/src/Mentions/MentionParser.php @@ -16,6 +16,32 @@ class MentionParser /** @return Collection */ public function parse(string $body): Collection + { + $ids = $this->parseRichEditorMentions($body); + + if ($ids->isNotEmpty()) { + return $ids; + } + + return $this->parsePlainTextMentions($body); + } + + /** @return Collection */ + protected function parseRichEditorMentions(string $body): Collection + { + preg_match_all('/data-type=["\']mention["\'][^>]*data-id=["\'](\d+)["\']/', $body, $matches); + + if (empty($matches[1])) { + preg_match_all('/data-id=["\'](\d+)["\'][^>]*data-type=["\']mention["\']/', $body, $matches); + } + + $ids = array_unique(array_map('intval', $matches[1] ?? [])); + + return collect($ids); + } + + /** @return Collection */ + protected function parsePlainTextMentions(string $body): Collection { $text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8'); diff --git a/src/Models/Comment.php b/src/Models/Comment.php index 9be057b..f519ecf 100644 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -2,6 +2,8 @@ namespace Relaticle\Comments\Models; +use Filament\Forms\Components\RichEditor\MentionProvider; +use Filament\Forms\Components\RichEditor\RichContentRenderer; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -145,6 +147,19 @@ class Comment extends Model public function renderBodyWithMentions(): string { $body = $this->body; + + if ($this->hasRichEditorMentions($body)) { + return RichContentRenderer::make($body) + ->mentions([ + MentionProvider::make('@') + ->getLabelsUsing(fn (array $ids): array => CommentsConfig::getCommenterModel()::query() + ->whereIn('id', $ids) + ->pluck('name', 'id') + ->all()), + ]) + ->toHtml(); + } + $mentionNames = $this->mentions->pluck('name')->filter()->unique(); foreach ($mentionNames as $name) { @@ -157,4 +172,9 @@ class Comment extends Model return $body; } + + protected function hasRichEditorMentions(string $body): bool + { + return str_contains($body, 'data-type="mention"') || str_contains($body, '

') || str_contains($body, '