mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-04-10 14:38:41 +00:00
✨ Add rich Markdown normalization and paste handling.
- 🛠 Introduce `RichTextMarkdownNormalizer` to convert Markdown and mixed input to cleaner HTML. - 🗂 Include a new Blade partial to enable Markdown-on-paste behavior in rich-text editors. - 📋 Enhance `create` and `edit` forms to normalize descriptions and support Markdown conversion. - 🧪 Add test coverage for Markdown normalization scenarios. - 🛠 Add CLI command to normalize project proposal descriptions in bulk. - 🔧 Update `vite.config.js` for improved development setup (e.g., ignored paths).
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\ProjectProposal;
|
||||
use App\Support\NostrAuth;
|
||||
use App\Support\RichTextMarkdownNormalizer;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
@@ -58,6 +59,11 @@ class extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function convertMarkdownToHtml(string $markdown): string
|
||||
{
|
||||
return (new RichTextMarkdownNormalizer)->toHtml($markdown);
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
Gate::forUser(NostrAuth::user())->authorize('create', ProjectProposal::class);
|
||||
@@ -82,7 +88,7 @@ class extends Component
|
||||
|
||||
$projectProposal = new ProjectProposal;
|
||||
$projectProposal->name = $this->form['name'];
|
||||
$projectProposal->description = $this->form['description'];
|
||||
$projectProposal->description = (new RichTextMarkdownNormalizer)->normalize($this->form['description']);
|
||||
$projectProposal->support_in_sats = (int) $this->form['support_in_sats'];
|
||||
$projectProposal->website = $this->form['website'];
|
||||
$projectProposal->accepted = $this->isAdmin ? $this->form['accepted'] : false;
|
||||
@@ -196,6 +202,8 @@ class extends Component
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('partials.markdown-paste-listener')
|
||||
@else
|
||||
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
|
||||
<flux:callout variant="warning" icon="exclamation-circle">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Models\ProjectProposal;
|
||||
use App\Support\NostrAuth;
|
||||
use App\Support\RichTextMarkdownNormalizer;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Attributes\Layout;
|
||||
@@ -77,6 +78,11 @@ class extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function convertMarkdownToHtml(string $markdown): string
|
||||
{
|
||||
return (new RichTextMarkdownNormalizer)->toHtml($markdown);
|
||||
}
|
||||
|
||||
public function update(): void
|
||||
{
|
||||
Gate::forUser(NostrAuth::user())->authorize('update', $this->project);
|
||||
@@ -104,7 +110,7 @@ class extends Component
|
||||
|
||||
$this->project->update([
|
||||
'name' => $this->form['name'],
|
||||
'description' => $this->form['description'],
|
||||
'description' => (new RichTextMarkdownNormalizer)->normalize($this->form['description']),
|
||||
'support_in_sats' => (int) $this->form['support_in_sats'],
|
||||
'website' => $this->form['website'],
|
||||
]);
|
||||
@@ -247,6 +253,8 @@ class extends Component
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('partials.markdown-paste-listener')
|
||||
@else
|
||||
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto">
|
||||
<flux:callout variant="warning" icon="exclamation-circle">
|
||||
|
||||
@@ -137,10 +137,8 @@ new class extends Component {
|
||||
<h1 class="text-2xl md:text-3xl text-zinc-800 dark:text-zinc-100 font-bold mb-2">
|
||||
{{ $projectProposal->name }}
|
||||
</h1>
|
||||
<div class="prose">
|
||||
<x-markdown>
|
||||
{!! $projectProposal->description !!}
|
||||
</x-markdown>
|
||||
<div class="prose dark:prose-invert max-w-none break-words [&_code]:break-all [&_a]:break-all">
|
||||
{!! $projectProposal->description !!}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
97
resources/views/partials/markdown-paste-listener.blade.php
Normal file
97
resources/views/partials/markdown-paste-listener.blade.php
Normal file
@@ -0,0 +1,97 @@
|
||||
@script
|
||||
<script>
|
||||
(() => {
|
||||
const MARKDOWN_PATTERNS = [
|
||||
/^\s{0,3}#{1,6}\s+\S/m,
|
||||
/^\s{0,3}[-*+]\s+\S/m,
|
||||
/^\s{0,3}\d{1,9}[.)]\s+\S/m,
|
||||
/^\s{0,3}>\s?/m,
|
||||
/^\s{0,3}```/m,
|
||||
/^\s*\S.*\n={3,}\s*$/m,
|
||||
/^\s*\S.*\n-{3,}\s*$/m,
|
||||
];
|
||||
|
||||
const looksLikeMarkdown = (text) => MARKDOWN_PATTERNS.some((re) => re.test(text));
|
||||
|
||||
const insertHtml = (editorEl, html) => {
|
||||
const tiptap = editorEl.editor;
|
||||
|
||||
if (tiptap && tiptap.commands && typeof tiptap.commands.insertContent === 'function') {
|
||||
tiptap.commands.insertContent(html);
|
||||
return true;
|
||||
}
|
||||
|
||||
const editable = editorEl.querySelector('[contenteditable="true"]');
|
||||
if (!editable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editable !== document.activeElement) {
|
||||
editable.focus();
|
||||
}
|
||||
|
||||
return document.execCommand('insertHTML', false, html);
|
||||
};
|
||||
|
||||
const attach = (editorEl) => {
|
||||
if (editorEl.dataset.mdPasteBound === '1') {
|
||||
return;
|
||||
}
|
||||
editorEl.dataset.mdPasteBound = '1';
|
||||
|
||||
editorEl.addEventListener(
|
||||
'paste',
|
||||
async (event) => {
|
||||
const clipboard = event.clipboardData;
|
||||
if (!clipboard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plain = clipboard.getData('text/plain');
|
||||
if (!plain || !looksLikeMarkdown(plain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
const rendered = await $wire.convertMarkdownToHtml(plain);
|
||||
if (!rendered) {
|
||||
return;
|
||||
}
|
||||
|
||||
insertHtml(editorEl, rendered);
|
||||
} catch (error) {
|
||||
console.error('Markdown paste conversion failed', error);
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
const scan = (root) => {
|
||||
(root || document).querySelectorAll('[data-flux-editor]').forEach(attach);
|
||||
};
|
||||
|
||||
scan(document);
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType !== 1) {
|
||||
return;
|
||||
}
|
||||
if (node.matches && node.matches('[data-flux-editor]')) {
|
||||
attach(node);
|
||||
}
|
||||
scan(node);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
})();
|
||||
</script>
|
||||
@endscript
|
||||
Reference in New Issue
Block a user