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
This commit is contained in:
@@ -33,14 +33,7 @@
|
||||
{{-- Body or edit form --}}
|
||||
@if ($isEditing)
|
||||
<form wire:submit="saveEdit" class="mt-1">
|
||||
<x-filament::input.wrapper>
|
||||
<textarea wire:model="editBody" rows="3"
|
||||
class="block w-full border-none bg-transparent px-3 py-1.5 text-sm leading-6 text-gray-950 outline-none transition duration-75 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
|
||||
></textarea>
|
||||
</x-filament::input.wrapper>
|
||||
@error('editBody')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
{{ $this->editForm }}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button type="submit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Save</button>
|
||||
<button type="button" wire:click="cancelEdit" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
|
||||
@@ -111,98 +104,8 @@
|
||||
|
||||
{{-- Reply form --}}
|
||||
@if ($isReplying)
|
||||
<form wire:submit="addReply" class="relative mt-3"
|
||||
x-data="{
|
||||
showMentions: false,
|
||||
mentionQuery: '',
|
||||
mentionResults: [],
|
||||
selectedIndex: 0,
|
||||
mentionStart: null,
|
||||
async handleInput(event) {
|
||||
const textarea = event.target;
|
||||
const value = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = value.substring(0, cursorPos);
|
||||
const atIndex = textBeforeCursor.lastIndexOf('@');
|
||||
if (atIndex !== -1 && (atIndex === 0 || textBeforeCursor[atIndex - 1] === ' ' || textBeforeCursor[atIndex - 1] === '\n')) {
|
||||
const query = textBeforeCursor.substring(atIndex + 1);
|
||||
if (query.length > 0 && !query.includes(' ')) {
|
||||
this.mentionStart = atIndex;
|
||||
this.mentionQuery = query;
|
||||
this.mentionResults = await $wire.searchUsers(query);
|
||||
this.showMentions = this.mentionResults.length > 0;
|
||||
this.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.showMentions = false;
|
||||
},
|
||||
handleKeydown(event) {
|
||||
if (!this.showMentions) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.mentionResults.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (this.mentionResults.length > 0) {
|
||||
event.preventDefault();
|
||||
this.selectMention(this.mentionResults[this.selectedIndex]);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
this.showMentions = false;
|
||||
}
|
||||
},
|
||||
selectMention(user) {
|
||||
const textarea = this.$refs.replyInput;
|
||||
const value = textarea.value;
|
||||
const before = value.substring(0, this.mentionStart);
|
||||
const after = value.substring(textarea.selectionStart);
|
||||
const newValue = before + '@' + user.name + ' ' + after;
|
||||
$wire.set('replyBody', newValue);
|
||||
this.showMentions = false;
|
||||
this.$nextTick(() => {
|
||||
const pos = before.length + user.name.length + 2;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
}">
|
||||
<x-filament::input.wrapper>
|
||||
<textarea x-ref="replyInput"
|
||||
wire:model="replyBody"
|
||||
@input="handleInput($event)"
|
||||
@keydown="handleKeydown($event)"
|
||||
rows="2"
|
||||
placeholder="Write a reply..."
|
||||
class="block w-full border-none bg-transparent px-3 py-1.5 text-sm leading-6 text-gray-950 outline-none transition duration-75 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
|
||||
></textarea>
|
||||
</x-filament::input.wrapper>
|
||||
|
||||
{{-- Mention autocomplete dropdown --}}
|
||||
<div x-show="showMentions" x-cloak
|
||||
class="absolute z-50 mt-1 w-64 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800">
|
||||
<template x-for="(user, index) in mentionResults" :key="user.id">
|
||||
<button type="button"
|
||||
@click="selectMention(user)"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<template x-if="user.avatar_url">
|
||||
<img :src="user.avatar_url" class="h-6 w-6 rounded-full object-cover" />
|
||||
</template>
|
||||
<template x-if="!user.avatar_url">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300"
|
||||
x-text="user.name.charAt(0).toUpperCase()"></div>
|
||||
</template>
|
||||
<span x-text="user.name" class="text-gray-900 dark:text-gray-100"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@error('replyBody')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
<form wire:submit="addReply" class="mt-3">
|
||||
{{ $this->replyForm }}
|
||||
|
||||
@if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
|
||||
<div class="mt-2">
|
||||
|
||||
@@ -60,99 +60,8 @@
|
||||
{{-- New comment form - only for authorized users --}}
|
||||
@auth
|
||||
@can('create', \Relaticle\Comments\CommentsConfig::getCommentModel())
|
||||
<form wire:submit="addComment" class="relative mt-4"
|
||||
x-data="{
|
||||
showMentions: false,
|
||||
mentionQuery: '',
|
||||
mentionResults: [],
|
||||
selectedIndex: 0,
|
||||
mentionStart: null,
|
||||
async handleInput(event) {
|
||||
const textarea = event.target;
|
||||
const value = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = value.substring(0, cursorPos);
|
||||
const atIndex = textBeforeCursor.lastIndexOf('@');
|
||||
if (atIndex !== -1 && (atIndex === 0 || textBeforeCursor[atIndex - 1] === ' ' || textBeforeCursor[atIndex - 1] === '\n')) {
|
||||
const query = textBeforeCursor.substring(atIndex + 1);
|
||||
if (query.length > 0 && !query.includes(' ')) {
|
||||
this.mentionStart = atIndex;
|
||||
this.mentionQuery = query;
|
||||
this.mentionResults = await $wire.searchUsers(query);
|
||||
this.showMentions = this.mentionResults.length > 0;
|
||||
this.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.showMentions = false;
|
||||
},
|
||||
handleKeydown(event) {
|
||||
if (!this.showMentions) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.mentionResults.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (this.mentionResults.length > 0) {
|
||||
event.preventDefault();
|
||||
this.selectMention(this.mentionResults[this.selectedIndex]);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
this.showMentions = false;
|
||||
}
|
||||
},
|
||||
selectMention(user) {
|
||||
const textarea = this.$refs.commentInput;
|
||||
const value = textarea.value;
|
||||
const before = value.substring(0, this.mentionStart);
|
||||
const after = value.substring(textarea.selectionStart);
|
||||
const newValue = before + '@' + user.name + ' ' + after;
|
||||
$wire.set('newComment', newValue);
|
||||
this.showMentions = false;
|
||||
this.$nextTick(() => {
|
||||
const pos = before.length + user.name.length + 2;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
}">
|
||||
<x-filament::input.wrapper>
|
||||
<textarea
|
||||
x-ref="commentInput"
|
||||
wire:model="newComment"
|
||||
@input="handleInput($event)"
|
||||
@keydown="handleKeydown($event)"
|
||||
rows="3"
|
||||
placeholder="Write a comment..."
|
||||
class="block w-full border-none bg-transparent px-3 py-1.5 text-sm leading-6 text-gray-950 outline-none transition duration-75 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
|
||||
></textarea>
|
||||
</x-filament::input.wrapper>
|
||||
|
||||
{{-- Mention autocomplete dropdown --}}
|
||||
<div x-show="showMentions" x-cloak
|
||||
class="absolute z-50 mt-1 w-64 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800">
|
||||
<template x-for="(user, index) in mentionResults" :key="user.id">
|
||||
<button type="button"
|
||||
@click="selectMention(user)"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<template x-if="user.avatar_url">
|
||||
<img :src="user.avatar_url" class="h-6 w-6 rounded-full object-cover" />
|
||||
</template>
|
||||
<template x-if="!user.avatar_url">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300"
|
||||
x-text="user.name.charAt(0).toUpperCase()"></div>
|
||||
</template>
|
||||
<span x-text="user.name" class="text-gray-900 dark:text-gray-100"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@error('newComment')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
<form wire:submit="addComment" class="mt-4">
|
||||
{{ $this->commentForm }}
|
||||
|
||||
@if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
|
||||
<div class="mt-2">
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> */
|
||||
public ?array $editData = [];
|
||||
|
||||
public string $replyBody = '';
|
||||
/** @var array<string, mixed> */
|
||||
public ?array $replyData = [];
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
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<int, array{id: int, name: string, avatar_url: ?string}> */
|
||||
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');
|
||||
|
||||
@@ -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<string, mixed> */
|
||||
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<int, Comment> */
|
||||
@@ -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<int, array{id: int, name: string, avatar_url: ?string}> */
|
||||
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');
|
||||
|
||||
@@ -16,6 +16,32 @@ class MentionParser
|
||||
|
||||
/** @return Collection<int, int> */
|
||||
public function parse(string $body): Collection
|
||||
{
|
||||
$ids = $this->parseRichEditorMentions($body);
|
||||
|
||||
if ($ids->isNotEmpty()) {
|
||||
return $ids;
|
||||
}
|
||||
|
||||
return $this->parsePlainTextMentions($body);
|
||||
}
|
||||
|
||||
/** @return Collection<int, int> */
|
||||
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<int, int> */
|
||||
protected function parsePlainTextMentions(string $body): Collection
|
||||
{
|
||||
$text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8');
|
||||
|
||||
|
||||
@@ -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, '<p>') || str_contains($body, '<br');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user