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 { @@ -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 { @@ -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 { @@ -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 { @@ -185,6 +187,15 @@ export default class extends Controller {
// Convert links
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
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n');
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n');

177
assets/controllers/quill_controller.js

@ -1,39 +1,166 @@ @@ -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 = [
'<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() {
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();
}
}

26
assets/styles/article.css

@ -78,3 +78,29 @@ blockquote p { @@ -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:';
}

35
src/Controller/ArticleController.php

@ -103,7 +103,7 @@ class ArticleController extends AbstractController @@ -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 @@ -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 @@ -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 @@ -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(),
]);
}

55
src/Form/DataTransformer/CommaSeparatedToJsonTransformer.php

@ -8,32 +8,69 @@ class CommaSeparatedToJsonTransformer implements DataTransformerInterface @@ -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 [];
}
}
}

28
src/Form/DataTransformer/HtmlToMdTransformer.php

@ -2,6 +2,9 @@ @@ -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; @@ -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 @@ -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 @@ -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');
}

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

@ -20,7 +20,7 @@ class Content @@ -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;
}

4
templates/pages/article.html.twig

@ -14,6 +14,10 @@ @@ -14,6 +14,10 @@
{% 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') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
Copy coordinates

2
templates/pages/editor.html.twig

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

Loading…
Cancel
Save