From 5b54237457e4444a336b69fbcf19ce992ed6aacb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 10 Sep 2025 18:13:03 +0200 Subject: [PATCH] Update editor --- .../controllers/nostr_publish_controller.js | 17 +- assets/controllers/quill_controller.js | 177 +++++++++++++++--- assets/styles/article.css | 26 +++ src/Controller/ArticleController.php | 35 +++- .../CommaSeparatedToJsonTransformer.php | 55 +++++- .../DataTransformer/HtmlToMdTransformer.php | 28 ++- src/Twig/Components/Atoms/Content.php | 2 +- templates/pages/article.html.twig | 4 + templates/pages/editor.html.twig | 2 +- 9 files changed, 293 insertions(+), 53 deletions(-) diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr_publish_controller.js index c9dee6a..7f2d7c2 100644 --- a/assets/controllers/nostr_publish_controller.js +++ b/assets/controllers/nostr_publish_controller.js @@ -12,6 +12,7 @@ export default class extends Controller { try { console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); + console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); } catch (_) {} } @@ -61,7 +62,7 @@ export default class extends Controller { // Optionally redirect after successful publish setTimeout(() => { - window.location.href = `/article/d/${formData.slug}`; + window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; }, 2000); } catch (error) { @@ -95,8 +96,9 @@ export default class extends Controller { .filter(topic => topic.length > 0) .map(topic => topic.startsWith('#') ? topic : `#${topic}`); - // Generate slug from title - const slug = this.generateSlug(title); + // Reuse existing slug if provided on the container (editing), else generate from title + const existingSlug = (this.element.dataset.slug || '').trim(); + const slug = existingSlug || this.generateSlug(title); return { title, @@ -185,6 +187,15 @@ export default class extends Controller { // Convert links markdown = markdown.replace(/]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); + // Convert images (handle src/alt in any order) + markdown = markdown.replace(/]*>/gi, (imgTag) => { + const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); + const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); + const src = srcMatch ? srcMatch[1] : ''; + const alt = altMatch ? altMatch[1] : ''; + return src ? `![${alt}](${src})` : ''; + }); + // Convert lists markdown = markdown.replace(/]*>(.*?)<\/ul>/gis, '$1\n'); markdown = markdown.replace(/]*>(.*?)<\/ol>/gis, '$1\n'); diff --git a/assets/controllers/quill_controller.js b/assets/controllers/quill_controller.js index 173b4ec..2828c46 100644 --- a/assets/controllers/quill_controller.js +++ b/assets/controllers/quill_controller.js @@ -1,39 +1,166 @@ -import {Controller} from '@hotwired/stimulus'; +import { Controller } from '@hotwired/stimulus'; import Quill from 'quill'; import('quill/dist/quill.core.css'); import('quill/dist/quill.snow.css'); - export default class extends Controller { + connect() { + // --- 1) Custom IMG blot that supports alt --- + const BlockEmbed = Quill.import('blots/block/embed'); + class ImageAltBlot extends BlockEmbed { + static blotName = 'imageAlt'; // if you want to replace default, rename to 'image' + static tagName = 'IMG'; + + static create(value) { + const node = super.create(); + if (typeof value === 'string') { + node.setAttribute('src', value); + } else if (value && value.src) { + node.setAttribute('src', value.src); + if (value.alt) node.setAttribute('alt', value.alt); + } + node.setAttribute('draggable', 'false'); + return node; + } + + static value(node) { + return { + src: node.getAttribute('src') || '', + alt: node.getAttribute('alt') || '', + }; + } + } + Quill.register(ImageAltBlot); + + // --- 2) Tooltip UI (modeled on Quill's link tooltip) --- + const Tooltip = Quill.import('ui/tooltip'); + class ImageTooltip extends Tooltip { + constructor(quill, boundsContainer) { + super(quill, boundsContainer); + this.root.classList.add('ql-image-tooltip'); + this.root.innerHTML = [ + '', + '
', + '', + '', + '', + '', + '
', + ].join(''); + + this.srcInput = this.root.querySelector('.ql-image-src'); + this.altInput = this.root.querySelector('.ql-image-alt'); + this.action = this.root.querySelector('.ql-action'); + this.cancel = this.root.querySelector('.ql-cancel'); + + this.action.addEventListener('click', () => this.save()); + this.cancel.addEventListener('click', () => this.hide()); + + const keyHandler = (e) => { + if (e.key === 'Enter') { e.preventDefault(); this.save(); } + if (e.key === 'Escape') { e.preventDefault(); this.hide(); } + }; + this.srcInput.addEventListener('keydown', keyHandler); + this.altInput.addEventListener('keydown', keyHandler); + } + + edit(prefill = null) { + const range = this.quill.getSelection(true); + if (!range) return; + + const bounds = this.quill.getBounds(range); + this.show(); + this.position(bounds); - connect() { - const toolbarOptions = [ - ['bold', 'italic', 'strike'], - ['link', 'blockquote', 'code-block', 'image'], - [{ 'header': 1 }, { 'header': 2 }, { 'header': 3 }], - [{ list: 'ordered' }, { list: 'bullet' }], - ]; - - const options = { - theme: 'snow', - modules: { - toolbar: toolbarOptions, - } + this.root.classList.add('ql-editing'); + this.srcInput.value = prefill?.src || ''; + this.altInput.value = prefill?.alt || ''; + this.srcInput.focus(); + this.srcInput.select(); + } + + hide() { + this.root.classList.remove('ql-editing'); + super.hide(); + } + + save() { + const src = (this.srcInput.value || '').trim(); + const alt = (this.altInput.value || '').trim(); + + // basic safety: allow http(s) or data:image/* + if (!src || !/^https?:|^data:image\//i.test(src)) { + this.srcInput.focus(); + return; } - let quill = new Quill('#editor', options); - let target = document.querySelector('#editor_content'); + const range = this.quill.getSelection(true); + if (!range) return; + // If selection is on existing ImageAlt blot, replace it; otherwise insert new + const [blot, blotOffset] = this.quill.getLeaf(range.index); + const isImageBlot = blot && blot.domNode && blot.domNode.tagName === 'IMG'; - quill.on('text-change', function(delta, oldDelta, source) { - console.log('Text change!'); - console.log(delta); - console.log(oldDelta); - console.log(source); - // save as html - target.value = quill.root.innerHTML; - }); + if (isImageBlot) { + // delete current, insert new one + const idx = range.index - blotOffset; + this.quill.deleteText(idx, 1, 'user'); + this.quill.insertEmbed(idx, 'imageAlt', { src, alt }, 'user'); + this.quill.setSelection(idx + 1, 0, 'user'); + } else { + this.quill.insertEmbed(range.index, 'imageAlt', { src, alt }, 'user'); + this.quill.setSelection(range.index + 1, 0, 'user'); + } + this.hide(); + } } + // --- 3) Quill init --- + const toolbarOptions = [ + ['bold', 'italic', 'strike'], + ['link', 'blockquote', 'code-block', 'image'], + [{ header: 1 }, { header: 2 }, { header: 3 }], + [{ list: 'ordered' }, { list: 'bullet' }], + ]; + + const options = { + theme: 'snow', + modules: { + toolbar: toolbarOptions, + }, + }; + + // Use the element in this controller's scope + const editorEl = this.element.querySelector('#editor') || document.querySelector('#editor'); + const target = this.element.querySelector('#editor_content') || document.querySelector('#editor_content'); + + const quill = new Quill(editorEl, options); + + // One tooltip instance per editor + const imageTooltip = new ImageTooltip(quill, quill.root.parentNode); + + // Intercept toolbar 'image' to open our tooltip + quill.getModule('toolbar').addHandler('image', () => { + // If caret is on an IMG, prefill from it + const range = quill.getSelection(true); + let prefill = null; + if (range) { + const [blot] = quill.getLeaf(range.index); + if (blot?.domNode?.tagName === 'IMG') { + prefill = { + src: blot.domNode.getAttribute('src') || '', + alt: blot.domNode.getAttribute('alt') || '', + }; + } + } + imageTooltip.edit(prefill); + }); + + // Keep your hidden field synced as HTML + const sync = () => { if (target) target.value = quill.root.innerHTML; }; + quill.on('text-change', sync); + // initialize once + sync(); + } } diff --git a/assets/styles/article.css b/assets/styles/article.css index 856ff1d..a2cfb07 100644 --- a/assets/styles/article.css +++ b/assets/styles/article.css @@ -78,3 +78,29 @@ blockquote p { height: auto; aspect-ratio: 16/9; } + +.ql-snow .ql-tooltip.ql-image-tooltip { + white-space: nowrap; +} + +.ql-snow .ql-tooltip.ql-image-tooltip .ql-tooltip-editor { + display: inline-flex; + gap: .5rem; + align-items: center; +} + +.ql-snow .ql-tooltip.ql-image-tooltip input { + width: 220px; +} + +.ql-snow .ql-tooltip.ql-image-tooltip .ql-action::before { + content: 'Insert'; +} + +.ql-snow .ql-tooltip.ql-image-tooltip .ql-cancel::before { + content: 'Cancel'; +} + +.ql-snow .ql-tooltip.ql-image-tooltip::before { + content: 'Image:'; +} diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index baca93a..93bf545 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -103,7 +103,7 @@ class ArticleController extends AbstractController $cacheKey = 'article_' . $article->getEventId(); $cacheItem = $articlesCache->getItem($cacheKey); if (!$cacheItem->isHit()) { - $cacheItem->set($converter->convertToHtml($article->getContent())); + $cacheItem->set($converter->convertToHTML($article->getContent())); $articlesCache->save($cacheItem); } @@ -111,12 +111,24 @@ class ArticleController extends AbstractController $npub = $key->convertPublicKeyToBech32($article->getPubkey()); $author = $redisCacheService->getMetadata($npub); + // determine whether the logged-in user is the author + $canEdit = false; + $user = $this->getUser(); + if ($user) { + try { + $currentPubkey = $key->convertToHex($user->getUserIdentifier()); + $canEdit = ($currentPubkey === $article->getPubkey()); + } catch (\Throwable $e) { + $canEdit = false; + } + } return $this->render('pages/article.html.twig', [ 'article' => $article, 'author' => $author, 'npub' => $npub, 'content' => $cacheItem->get(), + 'canEdit' => $canEdit, ]); } @@ -125,18 +137,27 @@ class ArticleController extends AbstractController * @throws \Exception */ #[Route('/article-editor/create', name: 'editor-create')] - #[Route('/article-editor/edit/{id}', name: 'editor-edit')] - public function newArticle(Request $request, EntityManagerInterface $entityManager, $id = null): Response + #[Route('/article-editor/edit/{slug}', name: 'editor-edit-slug')] + public function newArticle(Request $request, EntityManagerInterface $entityManager, $slug = null): Response { - if (!$id) { + if (!$slug) { $article = new Article(); $article->setKind(KindsEnum::LONGFORM); $article->setCreatedAt(new \DateTimeImmutable()); $formAction = $this->generateUrl('editor-create'); } else { - $formAction = $this->generateUrl('editor-edit', ['id' => $id]); + $formAction = $this->generateUrl('editor-edit-slug', ['slug' => $slug]); $repository = $entityManager->getRepository(Article::class); - $article = $repository->find($id); + $slug = urldecode($slug); + $articles = $repository->findBy(['slug' => $slug]); + if (count($articles) === 0) { + throw $this->createNotFoundException('The article could not be found'); + } + // Sort by createdAt, get latest revision + usort($articles, function ($a, $b) { + return $b->getCreatedAt() <=> $a->getCreatedAt(); + }); + $article = end($articles); } if ($article->getPubkey() === null) { @@ -152,7 +173,7 @@ class ArticleController extends AbstractController // load template with content editor return $this->render('pages/editor.html.twig', [ 'article' => $article, - 'form' => $this->createForm(EditorType::class, $article)->createView(), + 'form' => $form->createView(), ]); } diff --git a/src/Form/DataTransformer/CommaSeparatedToJsonTransformer.php b/src/Form/DataTransformer/CommaSeparatedToJsonTransformer.php index 3b5ed31..51e4213 100644 --- a/src/Form/DataTransformer/CommaSeparatedToJsonTransformer.php +++ b/src/Form/DataTransformer/CommaSeparatedToJsonTransformer.php @@ -8,32 +8,69 @@ class CommaSeparatedToJsonTransformer implements DataTransformerInterface { /** - * Transforms an array to a comma-separated string. + * Transforms model data (array|json|string|null) to a comma-separated string for the view. * @inheritDoc */ public function transform(mixed $value): mixed { - if ($value === null) { + if ($value === null || $value === '') { return ''; } - $array = json_decode($value, true); + // Normalize to array + if (is_string($value)) { + // Try JSON first + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $array = $decoded; + } else { + // It's already a plain comma-separated string (legacy) — return as-is + return $value; + } + } elseif (is_array($value)) { + $array = $value; + } else { + // Unsupported type, return empty string + return ''; + } + + // Clean up values + $array = array_map(static fn($v) => is_string($v) ? trim($v) : $v, $array); + $array = array_filter($array, static fn($v) => is_string($v) && $v !== ''); return implode(',', $array); } /** - * Transforms a comma-separated string to an array. + * Transforms a comma-separated string from the view into an array for the model. * @inheritDoc */ public function reverseTransform(mixed $value): mixed { - if (!$value) { - return json_encode([]); + if ($value === null || $value === '') { + return []; } - $array = array_map('trim', explode(',', $value)); + // If it's already an array (e.g., programmatic set), normalize it + if (is_array($value)) { + $array = array_map(static fn($v) => is_string($v) ? trim($v) : $v, $value); + return array_values(array_filter($array, static fn($v) => is_string($v) && $v !== '')); + } + + // If it looks like JSON, accept it for robustness + if (is_string($value)) { + $decoded = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $array = array_map(static fn($v) => is_string($v) ? trim($v) : $v, $decoded); + return array_values(array_filter($array, static fn($v) => is_string($v) && $v !== '')); + } + + // Fallback: parse comma-separated list + $parts = array_map('trim', explode(',', $value)); + return array_values(array_filter($parts, static fn($v) => $v !== '')); + } - return json_encode($array); + // Unknown type -> empty array + return []; } -} \ No newline at end of file +} diff --git a/src/Form/DataTransformer/HtmlToMdTransformer.php b/src/Form/DataTransformer/HtmlToMdTransformer.php index dcf2fd8..68fe3da 100644 --- a/src/Form/DataTransformer/HtmlToMdTransformer.php +++ b/src/Form/DataTransformer/HtmlToMdTransformer.php @@ -2,6 +2,9 @@ namespace App\Form\DataTransformer; +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\MarkdownConverter; use League\HTMLToMarkdown\HtmlConverter; use Symfony\Component\Form\DataTransformerInterface; use Symfony\Component\Form\Exception\TransformationFailedException; @@ -9,11 +12,17 @@ use Symfony\Component\Form\Exception\TransformationFailedException; class HtmlToMdTransformer implements DataTransformerInterface { - private $converter; + private HtmlConverter $htmlToMd; + private MarkdownConverter $mdToHtml; public function __construct() { - $this->converter = new HtmlConverter(); + $this->htmlToMd = new HtmlConverter(); + + // Create a minimal Environment for Markdown -> HTML conversion used by the editor + $environment = new Environment([]); + $environment->addExtension(new CommonMarkCoreExtension()); + $this->mdToHtml = new MarkdownConverter($environment); } /** @@ -22,12 +31,17 @@ class HtmlToMdTransformer implements DataTransformerInterface */ public function transform(mixed $value): mixed { - if ($value === null) { + if ($value === null || $value === '') { return ''; } - // Optional: You can add a markdown-to-html conversion if needed - return $value; // You could return rendered markdown here. + try { + // Convert Markdown to HTML for the editor field + return (string) $this->mdToHtml->convert((string) $value); + } catch (\Throwable $e) { + // If conversion fails, fall back to raw value to avoid breaking the form + return (string) $value; + } } /** @@ -41,8 +55,8 @@ class HtmlToMdTransformer implements DataTransformerInterface } try { - // Convert HTML to Markdown - return $this->converter->convert($value); + // Convert HTML (from the editor) to Markdown for storage + return $this->htmlToMd->convert((string) $value); } catch (\Exception $e) { throw new TransformationFailedException('Failed to convert HTML to Markdown'); } diff --git a/src/Twig/Components/Atoms/Content.php b/src/Twig/Components/Atoms/Content.php index f2ddbe1..37445a5 100644 --- a/src/Twig/Components/Atoms/Content.php +++ b/src/Twig/Components/Atoms/Content.php @@ -20,7 +20,7 @@ class Content public function mount($content): void { try { - $this->parsed = $this->converter->convertToHtml($content); + $this->parsed = $this->converter->convertToHTML($content); } catch (CommonMarkException) { $this->parsed = $content; } diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 8131c5c..77f19e3 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -14,6 +14,10 @@ {% block body %} + {% if canEdit %} + Edit article + {% endif %} + {% if is_granted('ROLE_ADMIN') %}