tag rendering pass one

This commit is contained in:
Mark Gregory
2026-06-17 10:48:14 +01:00
parent 26e428cd5f
commit febdb7da7a
4 changed files with 147 additions and 37 deletions
@@ -84,6 +84,7 @@ final class TicketCommentStoreController extends Controller
*/
private function normaliseHtmlBody(string $body): string
{
$body = html_entity_decode($body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$body = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $body) ?: '';
$body = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $body) ?: '';
$body = preg_replace('/javascript\s*:/i', '', $body) ?: '';
@@ -153,17 +153,26 @@
</div>
@endif
@php
$oldCommentBody = html_entity_decode((string) old('commentBody'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$oldCommentBody = preg_replace('/<\s*(script|style)[^>]*>.*?<\s*\/\s*\1\s*>/is', '', $oldCommentBody) ?: '';
$oldCommentBody = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $oldCommentBody) ?: '';
$oldCommentBody = preg_replace('/javascript\s*:/i', '', $oldCommentBody) ?: '';
$oldCommentBody = strip_tags($oldCommentBody, '<p><br><strong><b><em><i><u><ul><ol><li><blockquote><pre><code><a>');
@endphp
<div>
<label for="ticket-comment-body" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Comment</label>
<div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="ticket-comment-body">
<button type="button" data-helpdesk-tag="strong" class="rounded-lg border px-2 py-1 text-xs font-black">B</button>
<button type="button" data-helpdesk-tag="em" class="rounded-lg border px-2 py-1 text-xs italic">I</button>
<button type="button" data-helpdesk-tag="u" class="rounded-lg border px-2 py-1 text-xs underline">U</button>
<button type="button" data-helpdesk-tag="ul" class="rounded-lg border px-2 py-1 text-xs">List</button>
<button type="button" data-helpdesk-wrap="<blockquote>" data-helpdesk-wrap-end="</blockquote>" class="rounded-lg border px-2 py-1 text-xs">Quote</button>
<button type="button" data-helpdesk-wrap="<code>" data-helpdesk-wrap-end="</code>" class="rounded-lg border px-2 py-1 text-xs">Code</button>
<label for="ticket-comment-body-editor" class="mb-2 block text-sm font-medium text-zinc-700 dark:text-zinc-200">Comment</label>
<div class="mb-2 flex flex-wrap gap-2" data-helpdesk-html-toolbar="ticket-comment-body-editor">
<button type="button" data-helpdesk-command="bold" class="rounded-lg border px-2 py-1 text-xs font-black">B</button>
<button type="button" data-helpdesk-command="italic" class="rounded-lg border px-2 py-1 text-xs italic">I</button>
<button type="button" data-helpdesk-command="underline" class="rounded-lg border px-2 py-1 text-xs underline">U</button>
<button type="button" data-helpdesk-command="insertUnorderedList" class="rounded-lg border px-2 py-1 text-xs">List</button>
<button type="button" data-helpdesk-block="blockquote" class="rounded-lg border px-2 py-1 text-xs">Quote</button>
<button type="button" data-helpdesk-block="code" class="rounded-lg border px-2 py-1 text-xs">Code</button>
</div>
<textarea id="ticket-comment-body" dusk="ticket-comment-editor" name="commentBody" rows="6" placeholder="Write a professional HTML response..." required class="block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">{{ old('commentBody') }}</textarea>
<div id="ticket-comment-body-editor" dusk="ticket-comment-editor" contenteditable="true" role="textbox" aria-multiline="true" data-helpdesk-rich-editor data-helpdesk-sync-target="ticket-comment-body" class="min-h-40 block w-full rounded-xl border border-zinc-300 bg-white p-3 text-sm text-zinc-900 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 prose prose-sm max-w-none dark:prose-invert">{!! $oldCommentBody !!}</div>
<textarea id="ticket-comment-body" name="commentBody" required class="sr-only" tabindex="-1" aria-hidden="true">{{ $oldCommentBody }}</textarea>
</div>
@can('update', $ticket)
@@ -200,32 +209,35 @@
if (insertButton) {
const form = insertButton.closest('[data-helpdesk-comment-form]');
const select = form?.querySelector('[data-canned-response-select]');
const editor = form?.querySelector('[data-helpdesk-rich-editor]');
const textarea = form?.querySelector('textarea[name="commentBody"]');
const selected = select?.selectedOptions?.[0];
const body = decodeHtmlEntities(selected?.dataset?.body || '');
const body = normaliseCannedHtml(selected?.dataset?.body || '');
if (textarea && body.trim() !== '') {
textarea.value = textarea.value.trim() === ''
if (editor && textarea && body.trim() !== '') {
editor.innerHTML = editor.innerHTML.trim() === ''
? body
: textarea.value.replace(/\s+$/, '') + "\n\n" + body;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
: editor.innerHTML.replace(/\s+$/, '') + '<br><br>' + body;
syncRichEditor(editor);
editor.focus();
}
return;
}
const toolbarButton = event.target.closest('[data-helpdesk-tag], [data-helpdesk-wrap]');
const toolbarButton = event.target.closest('[data-helpdesk-command], [data-helpdesk-block], [data-helpdesk-tag], [data-helpdesk-wrap]');
if (toolbarButton) {
const toolbar = toolbarButton.closest('[data-helpdesk-html-toolbar]');
const textarea = document.getElementById(toolbar?.dataset?.helpdeskHtmlToolbar || '');
const editor = document.getElementById(toolbar?.dataset?.helpdeskHtmlToolbar || '');
if (textarea) {
if (editor?.matches('[contenteditable="true"]')) {
applyRichEditorCommand(editor, toolbarButton);
} else if (editor) {
const tag = toolbarButton.dataset.helpdeskTag;
const before = toolbarButton.dataset.helpdeskWrap || (tag === 'ul' ? '<ul><li>' : `<${tag}>`);
const after = toolbarButton.dataset.helpdeskWrapEnd || (tag === 'ul' ? '</li></ul>' : `</${tag}>`);
wrapSelection(textarea, before, after);
wrapSelection(editor, before, after);
}
return;
@@ -270,7 +282,25 @@
}
});
document.addEventListener('submit', function (event) {
const form = event.target.closest('[data-helpdesk-comment-form]');
if (! form) {
return;
}
form.querySelectorAll('[data-helpdesk-rich-editor]').forEach((editor) => {
syncRichEditor(editor);
});
});
document.addEventListener('input', function (event) {
const richEditor = event.target.closest('[data-helpdesk-rich-editor]');
if (richEditor) {
syncRichEditor(richEditor);
}
const search = event.target.closest('[data-helpdesk-comment-search]');
if (search) {
@@ -319,6 +349,72 @@
return textarea.value;
}
function normaliseCannedHtml(value) {
let decoded = decodeHtmlEntities(value);
while (decoded.includes('&lt;') || decoded.includes('&gt;') || decoded.includes('&amp;lt;')) {
const next = decodeHtmlEntities(decoded);
if (next === decoded) {
break;
}
decoded = next;
}
return decoded;
}
function syncRichEditor(editor) {
const target = document.getElementById(editor.dataset.helpdeskSyncTarget || '');
if (! target) {
return;
}
target.value = editor.innerHTML.trim();
target.dispatchEvent(new Event('input', { bubbles: true }));
target.dispatchEvent(new Event('change', { bubbles: true }));
}
function applyRichEditorCommand(editor, button) {
editor.focus();
const command = button.dataset.helpdeskCommand;
if (command) {
document.execCommand(command, false, null);
syncRichEditor(editor);
return;
}
const block = button.dataset.helpdeskBlock;
if (block === 'blockquote') {
document.execCommand('formatBlock', false, 'blockquote');
syncRichEditor(editor);
return;
}
if (block === 'code') {
const selection = window.getSelection();
const selected = selection && selection.toString() !== '' ? selection.toString() : 'code';
document.execCommand('insertHTML', false, '<code>' + escapeHtml(selected) + '</code>');
syncRichEditor(editor);
}
}
function escapeHtml(value) {
const span = document.createElement('span');
span.textContent = value;
return span.innerHTML;
}
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-helpdesk-rich-editor]').forEach((editor) => syncRichEditor(editor));
updateCommentToggleButtons();
});
function wrapSelection(textarea, before, after) {
const start = textarea.selectionStart || 0;
const end = textarea.selectionEnd || 0;
@@ -12,21 +12,31 @@
$isInternal = (bool) $comment->is_internal;
$borderClass = $isInternal ? 'border-amber-300 dark:border-amber-700' : 'border-red-200 dark:border-red-900/70';
$railClass = $isInternal ? 'bg-amber-500' : 'bg-red-500';
$renderedBody = (string) $comment->body;
$renderedBody = html_entity_decode((string) $comment->body, ENT_QUOTES | ENT_HTML5, 'UTF-8');
while (str_contains($renderedBody, '&lt;') || str_contains($renderedBody, '&gt;') || str_contains($renderedBody, '&amp;lt;')) {
$decodedBody = html_entity_decode($renderedBody, ENT_QUOTES | ENT_HTML5, 'UTF-8');
if ($decodedBody === $renderedBody) {
break;
}
$renderedBody = $decodedBody;
}
if ($currentTicket !== null) {
$renderedBody = strtr($renderedBody, [
'{{ ticket.reference }}' => e((string) ($currentTicket->reference ?? '')),
'{{ ticket.subject }}' => e((string) ($currentTicket->subject ?? '')),
'{{ ticket.status }}' => e((string) ($currentTicket->status?->name ?? '')),
'{{ ticket.priority }}' => e((string) ($currentTicket->priority?->name ?? '')),
'{{ ticket.category }}' => e((string) ($currentTicket->category?->name ?? '')),
'{{ ticket.project }}' => e((string) ($currentTicket->project?->title ?? '')),
'{{ ticket.team }}' => e((string) ($currentTicket->team?->name ?? '')),
'{{ requester.name }}' => e((string) ($currentTicket->owner?->name ?? $currentTicket->user?->name ?? '')),
'{{ agent.name }}' => e((string) ($currentTicket->agent?->name ?? '')),
'{{ actor.name }}' => e((string) ($commentUser?->name ?? '')),
'{{ ticket.url }}' => e(route('helpdesk.tickets.show', ['ticket' => $currentTicket->getKey()])),
'{{ ticket.reference }}' => (string) e((string) ($currentTicket->reference ?? '')),
'{{ ticket.subject }}' => (string) e((string) ($currentTicket->subject ?? '')),
'{{ ticket.status }}' => (string) e((string) ($currentTicket->status?->name ?? '')),
'{{ ticket.priority }}' => (string) e((string) ($currentTicket->priority?->name ?? '')),
'{{ ticket.category }}' => (string) e((string) ($currentTicket->category?->name ?? '')),
'{{ ticket.project }}' => (string) e((string) ($currentTicket->project?->title ?? '')),
'{{ ticket.team }}' => (string) e((string) ($currentTicket->team?->name ?? '')),
'{{ requester.name }}' => (string) e((string) ($currentTicket->owner?->name ?? $currentTicket->user?->name ?? '')),
'{{ agent.name }}' => (string) e((string) ($currentTicket->agent?->name ?? '')),
'{{ actor.name }}' => (string) e((string) ($commentUser?->name ?? '')),
'{{ ticket.url }}' => (string) e(route('helpdesk.tickets.show', ['ticket' => $currentTicket->getKey()])),
]);
}
@@ -64,7 +64,7 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
'name' => 'Browser canned response',
'slug' => 'browser-canned-response',
'visibility' => 'agents',
'body' => 'Inserted canned browser response',
'body' => '<p>Inserted canned browser response</p>',
'is_active' => true,
'sort_order' => 1,
]);
@@ -76,8 +76,11 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
->assertSee('Browser response ticket')
->select('canned_response_id', (string) $cannedResponse->id)
->press('@insert-canned-response')
->assertInputValue('commentBody', 'Inserted canned browser response')
->type('@ticket-comment-editor', 'First browser response')
->assertInputValue('commentBody', '<p>Inserted canned browser response</p>');
$this->setHelpdeskEditor($browser, 'ticket-comment-editor', '<p>First browser response</p>');
$browser
->press('@post-comment')
->waitForText('First browser response', 10)
->assertSee('First browser response');
@@ -85,7 +88,7 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$comment = Comment::query()
->where('ticket_id', $ticket->id)
->whereNull('parent_id')
->where('body', 'First browser response')
->where('body', '<p>First browser response</p>')
->firstOrFail();
$browser->press('@reply-to-comment-'.$comment->id)
@@ -99,13 +102,13 @@ final class HelpdeskTicketBrowserTest extends DuskTestCase
$this->assertDatabaseHas('helpdesk_comments', [
'ticket_id' => $ticket->id,
'parent_id' => null,
'body' => 'First browser response',
'body' => '<p>First browser response</p>',
]);
$parentId = Comment::query()
->where('ticket_id', $ticket->id)
->whereNull('parent_id')
->where('body', 'First browser response')
->where('body', '<p>First browser response</p>')
->value('id');
$this->assertDatabaseHas('helpdesk_comments', [