From b0057682fee3eddb676723457c3c6b8a7913e65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Wed, 5 Nov 2025 20:12:10 +0100 Subject: [PATCH] TeX support --- .../controllers/nostr_publish_controller.js | 4 + assets/controllers/quill_controller.js | 348 +++++++++++++++--- src/Service/NostrClient.php | 13 +- 3 files changed, 317 insertions(+), 48 deletions(-) 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 { '
', '', '', - '', - '', + 'Insert', + 'Cancel', '
', ].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 `![${e.imageAlt.alt || 'image'}](${e.imageAlt.src || ''})`; + if (e.image) return `![image](${e.image})`; + 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() + ]); + } } /**