diff --git a/assets/controllers/nostr_publish_controller.js b/assets/controllers/nostr_publish_controller.js index 455869b..0646170 100644 --- a/assets/controllers/nostr_publish_controller.js +++ b/assets/controllers/nostr_publish_controller.js @@ -480,6 +480,10 @@ export default class extends Controller { markdown = markdown.replace(/
]*>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n');
markdown = markdown.replace(/]*>(.*?)<\/code>/gi, '`$1`');
+ // Escape "_" inside display math $$...$$ and inline math $...$
+ markdown = markdown.replace(/\$\$([\s\S]*?)\$\$/g, (m, g1) => `$$${g1.replace(/_/g, (u, i, s) => (i>0 && s[i-1]==='\\') ? '\\_' : '\\_')}$$`);
+ markdown = markdown.replace(/$([^$]*?)$/g, (m, g1) => `$${g1.replace(/_/g, (u, i, s) => (i>0 && s[i-1]==='\\') ? '\\_' : '\\_')}$`);
+
// Clean up HTML entities and remaining tags
markdown = markdown.replace(/ /g, ' ');
markdown = markdown.replace(/&/g, '&');
diff --git a/assets/controllers/quill_controller.js b/assets/controllers/quill_controller.js
index 6f945f6..879785f 100644
--- a/assets/controllers/quill_controller.js
+++ b/assets/controllers/quill_controller.js
@@ -1,15 +1,22 @@
import { Controller } from '@hotwired/stimulus';
import Quill from 'quill';
-import('quill/dist/quill.core.css');
-import('quill/dist/quill.snow.css');
+import 'quill/dist/quill.core.css';
+import 'quill/dist/quill.snow.css';
+import 'katex/dist/katex.min.css';
+
+// KaTeX must be global for Quill's formula module
+import * as katex from 'katex';
+window.katex = katex;
export default class extends Controller {
+ static targets = ['hidden', 'markdown'] // hidden = HTML, markdown = MD
+
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 blotName = 'imageAlt'; // (set to 'image' to override default)
static tagName = 'IMG';
static create(value) {
@@ -33,7 +40,7 @@ export default class extends Controller {
}
Quill.register(ImageAltBlot);
- // --- 2) Tooltip UI (modeled on Quill's link tooltip) ---
+ // --- 2) Simple image tooltip (URL + alt) ---
const Tooltip = Quill.import('ui/tooltip');
class ImageTooltip extends Tooltip {
constructor(quill, boundsContainer) {
@@ -44,8 +51,8 @@ export default class extends Controller {
'',
].join('');
@@ -68,11 +75,9 @@ export default class extends Controller {
edit(prefill = null) {
const range = this.quill.getSelection(true);
if (!range) return;
-
const bounds = this.quill.getBounds(range);
this.show();
this.position(bounds);
-
this.root.classList.add('ql-editing');
this.srcInput.value = prefill?.src || '';
this.altInput.value = prefill?.alt || '';
@@ -88,22 +93,17 @@ export default class extends Controller {
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;
}
-
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';
+ const isImg = blot?.domNode?.tagName === 'IMG';
- if (isImageBlot) {
- // delete current, insert new one
+ if (isImg) {
const idx = range.index - blotOffset;
this.quill.deleteText(idx, 1, 'user');
this.quill.insertEmbed(idx, 'imageAlt', { src, alt }, 'user');
@@ -119,7 +119,7 @@ export default class extends Controller {
// --- 3) Quill init ---
const toolbarOptions = [
['bold', 'italic', 'strike'],
- ['link', 'blockquote', 'code-block', 'image'],
+ ['link', 'blockquote', 'code-block', 'image'], // 'formula' can be added if needed
[{ header: 1 }, { header: 2 }, { header: 3 }],
[{ list: 'ordered' }, { list: 'bullet' }],
];
@@ -131,22 +131,29 @@ export default class extends Controller {
},
};
- // 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');
+ // Root editor element & hidden target
+ const editorEl =
+ this.element.querySelector('#editor') ||
+ document.querySelector('#editor');
- const quill = new Quill(editorEl, options);
+ // Before initializing Quill, check if there's existing HTML with formulas
+ const existingHTML = editorEl.innerHTML.trim();
+ const hasFormulas = existingHTML.includes('ql-formula');
- // One tooltip instance per editor
- const imageTooltip = new ImageTooltip(quill, quill.root.parentNode);
+ this.quill = new Quill(editorEl, options);
- // 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);
+ // If there were formulas in the loaded HTML, we need to convert them to proper embeds
+ if (hasFormulas) {
+ this.convertFormulasToEmbeds();
+ }
+
+ // Image tooltip wiring
+ const imageTooltip = new ImageTooltip(this.quill, this.quill.root.parentNode);
+ this.quill.getModule('toolbar').addHandler('image', () => {
+ const range = this.quill.getSelection(true);
let prefill = null;
if (range) {
- const [blot] = quill.getLeaf(range.index);
+ const [blot] = this.quill.getLeaf(range.index);
if (blot?.domNode?.tagName === 'IMG') {
prefill = {
src: blot.domNode.getAttribute('src') || '',
@@ -157,34 +164,283 @@ export default class extends Controller {
imageTooltip.edit(prefill);
});
-
- // Nostr highlights
- // Match common bech32 nostr URIs
+ // --- 4) Nostr link highlighting ---
const NOSTR_GLOBAL = /\bnostr:(?:note1|npub1|nprofile1|nevent1|naddr1|nrelay1|nsec1)[a-z0-9]+/gi;
-
- function highlightAll() {
- const text = quill.getText(); // includes trailing \n
- // Clear JUST the background attribute; leaves bold/italics/etc intact
- quill.formatText(0, text.length, { background: false }, 'api');
-
+ const highlightAll = () => {
+ const text = this.quill.getText(); // includes trailing \n
+ this.quill.formatText(0, text.length, { background: false }, 'api');
for (const m of text.matchAll(NOSTR_GLOBAL)) {
- quill.formatText(m.index, m[0].length, { background: 'rgba(168, 85, 247, 0.18)' }, 'api');
+ this.quill.formatText(m.index, m[0].length, { background: 'rgba(168, 85, 247, 0.18)' }, 'api');
}
- }
+ };
- // 1) First load
highlightAll();
-
- // 2) Keep it fresh on edits/paste
- quill.on('text-change', (delta, oldDelta, source) => {
+ this.quill.on('text-change', (delta, old, source) => {
if (source === 'user') highlightAll();
+ this.syncHiddenAsHtml();
});
+ const sync = () => {
+ // HTML
+ if (this.hasHiddenTarget) this.hiddenTarget.value = this.quill.root.innerHTML;
+ // Markdown (from Delta)
+ if (this.hasMarkdownTarget) this.markdownTarget.value = deltaToMarkdown(this.quill.getContents());
+ };
- // Keep your hidden field synced as HTML
- const sync = () => { if (target) target.value = quill.root.innerHTML; };
- quill.on('text-change', sync);
- // initialize once
+ // sync on load and on every edit
sync();
+ this.quill.on('text-change', (delta, oldDelta, source) => {
+ if (source === 'user') highlightAll();
+ sync();
+ });
+
+ // safety: also refresh MD/HTML right before a real submit (if any)
+ const form = this.element.closest('form');
+ if (form) {
+ form.addEventListener('submit', () => sync());
+ }
+ }
+
+ // keep hidden field updated with HTML while typing (optional)
+ syncHiddenAsHtml() {
+ if (!this.hasHiddenTarget) return;
+ this.hiddenTarget.value = this.quill.root.innerHTML;
+ }
+
+ // Convert formula spans in loaded HTML to proper Quill formula embeds
+ convertFormulasToEmbeds() {
+ const root = this.quill.root;
+ const formulaSpans = root.querySelectorAll('span.ql-formula');
+
+ if (formulaSpans.length === 0) return;
+
+ const deltaOps = [];
+
+ // Walk through DOM and rebuild the delta, converting formulas to embeds
+ const processNode = (node, attrs = {}) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ if (node.textContent) {
+ deltaOps.push({ insert: node.textContent, attributes: Object.keys(attrs).length ? attrs : undefined });
+ }
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
+ const tag = node.tagName.toLowerCase();
+
+ // Handle formula spans
+ if (node.classList && node.classList.contains('ql-formula')) {
+ const texValue = node.getAttribute('data-value');
+ if (texValue) {
+ deltaOps.push({ insert: { formula: texValue } });
+ }
+ return;
+ }
+
+ // Handle images
+ if (tag === 'img') {
+ const src = node.getAttribute('src');
+ const alt = node.getAttribute('alt');
+ if (src) {
+ deltaOps.push({ insert: { imageAlt: { src, alt: alt || '' } } });
+ }
+ return;
+ }
+
+ // Track inline formatting
+ const newAttrs = { ...attrs };
+ if (tag === 'strong') newAttrs.bold = true;
+ if (tag === 'em') newAttrs.italic = true;
+ if (tag === 'code') newAttrs.code = true;
+ if (tag === 's') newAttrs.strike = true;
+ if (tag === 'a') newAttrs.link = node.getAttribute('href');
+
+ // Handle block elements
+ if (tag === 'p' || tag === 'div' || tag === 'br') {
+ for (const child of node.childNodes) {
+ processNode(child, newAttrs);
+ }
+ if (tag === 'br' || (deltaOps.length > 0 && deltaOps[deltaOps.length - 1].insert !== '\n')) {
+ deltaOps.push({ insert: '\n' });
+ }
+ return;
+ }
+
+ // Handle headings
+ if (tag.match(/^h[1-6]$/)) {
+ for (const child of node.childNodes) {
+ processNode(child, newAttrs);
+ }
+ const level = parseInt(tag[1]);
+ deltaOps.push({ insert: '\n', attributes: { header: level } });
+ return;
+ }
+
+ // Handle lists
+ if (tag === 'li') {
+ const parent = node.parentElement;
+ const listType = parent?.tagName.toLowerCase() === 'ol' ? 'ordered' : 'bullet';
+ for (const child of node.childNodes) {
+ processNode(child, newAttrs);
+ }
+ deltaOps.push({ insert: '\n', attributes: { list: listType } });
+ return;
+ }
+
+ // Handle blockquotes
+ if (tag === 'blockquote') {
+ for (const child of node.childNodes) {
+ processNode(child, newAttrs);
+ }
+ if (deltaOps.length > 0 && deltaOps[deltaOps.length - 1].insert !== '\n') {
+ deltaOps.push({ insert: '\n', attributes: { blockquote: true } });
+ }
+ return;
+ }
+
+ // Handle code blocks
+ if (tag === 'pre') {
+ const codeContent = node.textContent || '';
+ deltaOps.push({ insert: codeContent });
+ deltaOps.push({ insert: '\n', attributes: { 'code-block': true } });
+ return;
+ }
+
+ // Default: process children
+ for (const child of node.childNodes) {
+ processNode(child, newAttrs);
+ }
+ }
+ };
+
+ for (const child of root.childNodes) {
+ processNode(child);
+ }
+
+ // Ensure delta ends with a newline
+ if (deltaOps.length === 0 || deltaOps[deltaOps.length - 1].insert !== '\n') {
+ deltaOps.push({ insert: '\n' });
+ }
+
+ this.quill.setContents(deltaOps, 'silent');
+ }
+}
+
+/* ---------- Delta → Markdown with $...$ / $$...$$ ---------- */
+function escapeUnderscoresInTeXForPosting(tex) {
+ if (!tex) return tex;
+ // Double-escape underscore for posting: produce two backslashes before _ at runtime
+ return tex.replace(/_/g, "\\_");
+}
+
+function deltaToMarkdown(delta) {
+ const ops = delta.ops || [];
+
+ // Build logical lines; the attributes on the *newline* op define the block
+ const lines = [];
+ let inlines = [];
+ let pendingBlock = {}; // attrs from the newline that ended the line
+
+ const pushText = (text, attrs) => {
+ if (!text) return;
+ inlines.push({ type: 'text', text, attrs: attrs || null });
+ };
+ const pushEmbed = (embed, attrs) => {
+ inlines.push({ type: 'embed', embed, attrs: attrs || null });
+ };
+ const endLine = (newlineAttrs) => {
+ lines.push({ inlines, block: newlineAttrs || pendingBlock || {} });
+ inlines = [];
+ pendingBlock = {};
+ };
+
+ for (const op of ops) {
+ const attrs = op.attributes || null;
+
+ if (typeof op.insert === 'string') {
+ // Split by '\n' but preserve the newline attrs as the block style
+ const parts = op.insert.split('\n');
+ for (let i = 0; i < parts.length; i++) {
+ if (parts[i]) pushText(parts[i], attrs);
+ if (i < parts.length - 1) {
+ // This newline ends the line: its attrs define header/list/blockquote
+ endLine(attrs);
+ }
+ }
+ } else if (op.insert) {
+ pushEmbed(op.insert, attrs);
+ }
+ }
+ if (inlines.length) lines.push({ inlines, block: pendingBlock || {} });
+
+ const stripZW = (s) => s.replace(/[\u200B\u200C\u200D\u2060\uFEFF]/g, '');
+
+ const renderInline = (seg) => {
+ if (seg.type === 'embed') {
+ const e = seg.embed;
+ if (e.formula) return `$${escapeUnderscoresInTeXForPosting(e.formula)}$`;
+ if (e.imageAlt) return ``;
+ if (e.image) return ``;
+ return '[embed]';
+ }
+ let t = stripZW(seg.text);
+ const a = seg.attrs || {};
+ if (a.code) t = '`' + t + '`';
+ if (a.italic) t = '*' + t + '*';
+ if (a.bold) t = '**' + t + '**';
+ if (a.link) t = `[${t}](${a.link})`;
+ return t;
+ };
+
+ // Tolerant display-math detection: allow surrounding whitespace only
+ const isDisplayFormulaLine = (inlines) => {
+ let tex = null;
+ for (const seg of inlines) {
+ if (seg.type === 'embed' && seg.embed?.formula) {
+ if (tex !== null) return null; // more than one formula
+ tex = seg.embed.formula;
+ } else if (seg.type === 'text' && stripZW(seg.text).trim() === '') {
+ // whitespace ok
+ } else {
+ return null; // other content present
+ }
+ }
+ return tex ? tex : null;
+ };
+
+ const mdLines = [];
+
+ for (const { inlines: L, block } of lines) {
+ // Display math line
+ const dispTex = isDisplayFormulaLine(L);
+ if (dispTex) {
+ mdLines.push('$$\n' + escapeUnderscoresInTeXForPosting(dispTex) + '\n$$');
+ continue;
+ }
+
+ // Inline content
+ const content = L.map(renderInline).join('');
+
+ // Block styling (header/list/blockquote/code)
+ if (block['code-block']) { mdLines.push('```\n' + content + '\n```'); continue; }
+ if (block.blockquote) { mdLines.push('> ' + content); continue; }
+
+ const h = block.header;
+ if (h >= 1 && h <= 6) {
+ mdLines.push(`${'#'.repeat(h)} ${content}`);
+ continue;
+ }
+
+ if (block.list === 'ordered') { mdLines.push(`1. ${content}`); continue; }
+ if (block.list === 'bullet') { mdLines.push(`- ${content}`); continue; }
+
+ mdLines.push(content);
}
+
+ // Normalize spacing: a blank line after headings & code blocks helps some renderers
+ let out = mdLines.join('\n');
+ // Escape "_" inside display math $$...$$ and inline math $...$
+ out = out.replace(/\$\$([\s\S]*?)\$\$/g, (m, g1) => `$$${g1.replace(/_/g, (u, i, s) => (i>0 && s[i-1]==='\\') ? '\\_' : '\\_')}$$`);
+ out = out.replace(/\$([^$]*?)\$/g, (m, g1) => `$${g1.replace(/_/g, (u, i, s) => (i>0 && s[i-1]==='\\') ? '\\_' : '\\_')}$`);
+
+ out = out.replace(/(\n?^#{1,6} .*$)/gm, '$1'); // keep as-is
+ out = out.replace(/\n{3,}/g, '\n\n');
+ return out.trim();
}
diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php
index 81a0b6d..572f8ea 100644
--- a/src/Service/NostrClient.php
+++ b/src/Service/NostrClient.php
@@ -160,8 +160,17 @@ class NostrClient
// Use relay pool instead of creating new Relay instances
$relaySet = $this->createRelaySet($relays);
$relaySet->setMessage($eventMessage);
- // TODO handle responses appropriately
- return $relaySet->send();
+ try {
+ $this->logger->info('Publishing event to relays', [
+ 'event_id' => $event->getId(),
+ 'relays' => $relays
+ ]);
+ return $relaySet->send();
+ } catch (\Exception $e) {
+ $this->logger->error('Error logging publish event', [
+ 'error' => $e->getMessage()
+ ]);
+ }
}
/**