Browse Source

Update editor

imwald
Nuša Pukšič 4 months ago
parent
commit
5b54237457
  1. 17
      assets/controllers/nostr_publish_controller.js
  2. 177
      assets/controllers/quill_controller.js
  3. 26
      assets/styles/article.css
  4. 35
      src/Controller/ArticleController.php
  5. 55
      src/Form/DataTransformer/CommaSeparatedToJsonTransformer.php
  6. 28
      src/Form/DataTransformer/HtmlToMdTransformer.php
  7. 2
      src/Twig/Components/Atoms/Content.php
  8. 4
      templates/pages/article.html.twig
  9. 2
      templates/pages/editor.html.twig

17
assets/controllers/nostr_publish_controller.js

@ -12,6 +12,7 @@ export default class extends Controller {
try { try {
console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)');
console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue));
console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)'));
} catch (_) {} } catch (_) {}
} }
@ -61,7 +62,7 @@ export default class extends Controller {
// Optionally redirect after successful publish // Optionally redirect after successful publish
setTimeout(() => { setTimeout(() => {
window.location.href = `/article/d/${formData.slug}`; window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`;
}, 2000); }, 2000);
} catch (error) { } catch (error) {
@ -95,8 +96,9 @@ export default class extends Controller {
.filter(topic => topic.length > 0) .filter(topic => topic.length > 0)
.map(topic => topic.startsWith('#') ? topic : `#${topic}`); .map(topic => topic.startsWith('#') ? topic : `#${topic}`);
// Generate slug from title // Reuse existing slug if provided on the container (editing), else generate from title
const slug = this.generateSlug(title); const existingSlug = (this.element.dataset.slug || '').trim();
const slug = existingSlug || this.generateSlug(title);
return { return {
title, title,
@ -185,6 +187,15 @@ export default class extends Controller {
// Convert links // Convert links
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
// Convert images (handle src/alt in any order)
markdown = markdown.replace(/<img\b[^>]*>/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 // Convert lists
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n'); markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n');
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n'); markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n');

177
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 from 'quill';
import('quill/dist/quill.core.css'); import('quill/dist/quill.core.css');
import('quill/dist/quill.snow.css'); import('quill/dist/quill.snow.css');
export default class extends Controller { 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 = [
'<span class="ql-tooltip-arrow"></span>',
'<div class="ql-tooltip-editor">',
'<input class="ql-image-src" type="text" placeholder="Image URL" />',
'<input class="ql-image-alt" type="text" placeholder="Alt text" />',
'<a class="ql-action"></a>',
'<a class="ql-cancel"></a>',
'</div>',
].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() { this.root.classList.add('ql-editing');
const toolbarOptions = [ this.srcInput.value = prefill?.src || '';
['bold', 'italic', 'strike'], this.altInput.value = prefill?.alt || '';
['link', 'blockquote', 'code-block', 'image'], this.srcInput.focus();
[{ 'header': 1 }, { 'header': 2 }, { 'header': 3 }], this.srcInput.select();
[{ list: 'ordered' }, { list: 'bullet' }], }
];
hide() {
const options = { this.root.classList.remove('ql-editing');
theme: 'snow', super.hide();
modules: { }
toolbar: toolbarOptions,
} 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); const range = this.quill.getSelection(true);
let target = document.querySelector('#editor_content'); 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) { if (isImageBlot) {
console.log('Text change!'); // delete current, insert new one
console.log(delta); const idx = range.index - blotOffset;
console.log(oldDelta); this.quill.deleteText(idx, 1, 'user');
console.log(source); this.quill.insertEmbed(idx, 'imageAlt', { src, alt }, 'user');
// save as html this.quill.setSelection(idx + 1, 0, 'user');
target.value = quill.root.innerHTML; } 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();
}
} }

26
assets/styles/article.css

@ -78,3 +78,29 @@ blockquote p {
height: auto; height: auto;
aspect-ratio: 16/9; 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:';
}

35
src/Controller/ArticleController.php

@ -103,7 +103,7 @@ class ArticleController extends AbstractController
$cacheKey = 'article_' . $article->getEventId(); $cacheKey = 'article_' . $article->getEventId();
$cacheItem = $articlesCache->getItem($cacheKey); $cacheItem = $articlesCache->getItem($cacheKey);
if (!$cacheItem->isHit()) { if (!$cacheItem->isHit()) {
$cacheItem->set($converter->convertToHtml($article->getContent())); $cacheItem->set($converter->convertToHTML($article->getContent()));
$articlesCache->save($cacheItem); $articlesCache->save($cacheItem);
} }
@ -111,12 +111,24 @@ class ArticleController extends AbstractController
$npub = $key->convertPublicKeyToBech32($article->getPubkey()); $npub = $key->convertPublicKeyToBech32($article->getPubkey());
$author = $redisCacheService->getMetadata($npub); $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', [ return $this->render('pages/article.html.twig', [
'article' => $article, 'article' => $article,
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'content' => $cacheItem->get(), 'content' => $cacheItem->get(),
'canEdit' => $canEdit,
]); ]);
} }
@ -125,18 +137,27 @@ class ArticleController extends AbstractController
* @throws \Exception * @throws \Exception
*/ */
#[Route('/article-editor/create', name: 'editor-create')] #[Route('/article-editor/create', name: 'editor-create')]
#[Route('/article-editor/edit/{id}', name: 'editor-edit')] #[Route('/article-editor/edit/{slug}', name: 'editor-edit-slug')]
public function newArticle(Request $request, EntityManagerInterface $entityManager, $id = null): Response public function newArticle(Request $request, EntityManagerInterface $entityManager, $slug = null): Response
{ {
if (!$id) { if (!$slug) {
$article = new Article(); $article = new Article();
$article->setKind(KindsEnum::LONGFORM); $article->setKind(KindsEnum::LONGFORM);
$article->setCreatedAt(new \DateTimeImmutable()); $article->setCreatedAt(new \DateTimeImmutable());
$formAction = $this->generateUrl('editor-create'); $formAction = $this->generateUrl('editor-create');
} else { } else {
$formAction = $this->generateUrl('editor-edit', ['id' => $id]); $formAction = $this->generateUrl('editor-edit-slug', ['slug' => $slug]);
$repository = $entityManager->getRepository(Article::class); $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) { if ($article->getPubkey() === null) {
@ -152,7 +173,7 @@ class ArticleController extends AbstractController
// load template with content editor // load template with content editor
return $this->render('pages/editor.html.twig', [ return $this->render('pages/editor.html.twig', [
'article' => $article, 'article' => $article,
'form' => $this->createForm(EditorType::class, $article)->createView(), 'form' => $form->createView(),
]); ]);
} }

55
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 * @inheritDoc
*/ */
public function transform(mixed $value): mixed public function transform(mixed $value): mixed
{ {
if ($value === null) { if ($value === null || $value === '') {
return ''; 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); 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 * @inheritDoc
*/ */
public function reverseTransform(mixed $value): mixed public function reverseTransform(mixed $value): mixed
{ {
if (!$value) { if ($value === null || $value === '') {
return json_encode([]); 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 [];
} }
} }

28
src/Form/DataTransformer/HtmlToMdTransformer.php

@ -2,6 +2,9 @@
namespace App\Form\DataTransformer; namespace App\Form\DataTransformer;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\MarkdownConverter;
use League\HTMLToMarkdown\HtmlConverter; use League\HTMLToMarkdown\HtmlConverter;
use Symfony\Component\Form\DataTransformerInterface; use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException; use Symfony\Component\Form\Exception\TransformationFailedException;
@ -9,11 +12,17 @@ use Symfony\Component\Form\Exception\TransformationFailedException;
class HtmlToMdTransformer implements DataTransformerInterface class HtmlToMdTransformer implements DataTransformerInterface
{ {
private $converter; private HtmlConverter $htmlToMd;
private MarkdownConverter $mdToHtml;
public function __construct() 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 public function transform(mixed $value): mixed
{ {
if ($value === null) { if ($value === null || $value === '') {
return ''; return '';
} }
// Optional: You can add a markdown-to-html conversion if needed try {
return $value; // You could return rendered markdown here. // 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 { try {
// Convert HTML to Markdown // Convert HTML (from the editor) to Markdown for storage
return $this->converter->convert($value); return $this->htmlToMd->convert((string) $value);
} catch (\Exception $e) { } catch (\Exception $e) {
throw new TransformationFailedException('Failed to convert HTML to Markdown'); throw new TransformationFailedException('Failed to convert HTML to Markdown');
} }

2
src/Twig/Components/Atoms/Content.php

@ -20,7 +20,7 @@ class Content
public function mount($content): void public function mount($content): void
{ {
try { try {
$this->parsed = $this->converter->convertToHtml($content); $this->parsed = $this->converter->convertToHTML($content);
} catch (CommonMarkException) { } catch (CommonMarkException) {
$this->parsed = $content; $this->parsed = $content;
} }

4
templates/pages/article.html.twig

@ -14,6 +14,10 @@
{% block body %} {% block body %}
{% if canEdit %}
<a class="btn btn-primary" href="{{ path('editor-edit-slug', {'slug': article.slug}) }}">Edit article</a>
{% endif %}
{% if is_granted('ROLE_ADMIN') %} {% if is_granted('ROLE_ADMIN') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')"> <button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
Copy coordinates Copy coordinates

2
templates/pages/editor.html.twig

@ -16,7 +16,7 @@
<div {{ stimulus_controller('nostr-publish', { <div {{ stimulus_controller('nostr-publish', {
publishUrl: path('api-article-publish'), publishUrl: path('api-article-publish'),
csrfToken: csrf_token('nostr_publish') csrfToken: csrf_token('nostr_publish')
}) }} data-nostr-publish-target="form"> }) }} data-nostr-publish-target="form" data-slug="{{ article.slug|default('') }}">
<!-- Status messages --> <!-- Status messages -->
<div data-nostr-publish-target="status"></div> <div data-nostr-publish-target="status"></div>

Loading…
Cancel
Save